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,996 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from importlib import import_module
6
+ from os import PathLike
7
+ from pathlib import Path
8
+ from typing import Any, ClassVar, Mapping, Sequence, cast
9
+
10
+ from hwpx.document import HwpxDocument
11
+ from hwpx.tools.id_integrity import check_id_integrity
12
+ from hwpx.tools.idempotence import IdempotenceReport, check_idempotent_pair
13
+ from hwpx.tools.package_reconcile import reconcile_package_with_document
14
+ from hwpx.tools.package_validator import validate_editor_open_safety
15
+ from hwpx.tools.package_validator import validate_package
16
+ from hwpx.tools.validator import validate_document
17
+
18
+ from .report import BuilderSaveReport, BuilderVerifyReport, ReopenReport
19
+
20
+
21
+ BuilderChild = (
22
+ "Heading | Paragraph | Bullet | NumberedList | Table | Image | Toc | NativeToc | PageBreak"
23
+ )
24
+ _HWP_UNITS_PER_MM = 7200 / 25.4
25
+ _A4_HWP_SIZE = (59528, 84188)
26
+
27
+ # Builder presets hook at Document.lower(), where a single
28
+ # preset context can be passed into Heading/Run/Bullet lowering without
29
+ # changing default node contracts or the plan-v1 authoring style-token path.
30
+
31
+
32
+ def _outline_style_refs(document: HwpxDocument, level: int) -> dict[str, str | int]:
33
+ """Return paragraph style refs for the built-in HWP outline level, if present."""
34
+
35
+ safe_level = min(10, max(1, int(level)))
36
+ for style in document.styles.values():
37
+ name = str(style.name or "")
38
+ eng_name = str(style.eng_name or "")
39
+ if name == f"개요 {safe_level}" or eng_name == f"Outline {safe_level}":
40
+ refs: dict[str, str | int] = {}
41
+ style_id = style.raw_id if style.raw_id is not None else style.id
42
+ if style_id is None:
43
+ continue
44
+ refs["style_id_ref"] = style_id
45
+ if style.para_pr_id_ref is not None:
46
+ refs["para_pr_id_ref"] = int(style.para_pr_id_ref)
47
+ return refs
48
+ return {}
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class _BuilderPreset:
53
+ name: str = "default"
54
+
55
+ @property
56
+ def is_government_report(self) -> bool:
57
+ return self.name == "government_report"
58
+
59
+ def heading_style(self, level: int) -> dict[str, Any]:
60
+ if self.is_government_report:
61
+ size_by_level = {1: 16, 2: 14, 3: 12}
62
+ color_by_level = {1: "1F4E79", 2: "2F5597", 3: "404040"}
63
+ return {
64
+ "bold": True,
65
+ "underline": level == 1,
66
+ "size": size_by_level[level],
67
+ "font": "함초롬바탕",
68
+ "color": color_by_level[level],
69
+ }
70
+ size_by_level = {1: 18, 2: 15, 3: 13}
71
+ return {
72
+ "bold": True,
73
+ "size": size_by_level[level],
74
+ "font": "함초롬바탕",
75
+ }
76
+
77
+ def paragraph_style(self, style: str | None) -> dict[str, Any] | None:
78
+ if not self.is_government_report:
79
+ return None
80
+ normalized = (style or "").strip().lower()
81
+ if normalized == "callout":
82
+ return {
83
+ "bold": True,
84
+ "color": "1F4E79",
85
+ "font": "함초롬바탕",
86
+ "highlight": "EAF1FB",
87
+ }
88
+ if normalized in {"emphasis", "gov_emphasis"}:
89
+ return {"bold": True, "color": "1F4E79", "font": "함초롬바탕"}
90
+ return None
91
+
92
+ def run_style(self, run: "Run") -> dict[str, Any]:
93
+ color = run.color
94
+ font = run.font
95
+ if self.is_government_report:
96
+ if run.bold and color is None:
97
+ color = "1F4E79"
98
+ if (run.bold or run.underline or run.highlight) and font is None:
99
+ font = "함초롬바탕"
100
+ return {
101
+ "bold": run.bold,
102
+ "italic": run.italic,
103
+ "underline": run.underline,
104
+ "color": color,
105
+ "font": font,
106
+ "size": run.size,
107
+ "highlight": run.highlight,
108
+ "strike": True if run.strike else None,
109
+ }
110
+
111
+ def bullet_char(self, *, level: int, style: str | None = None) -> str:
112
+ if self.is_government_report:
113
+ style_chars = {
114
+ "default": "•",
115
+ "square": "□",
116
+ "circle": "○",
117
+ "dash": "-",
118
+ "note": "※",
119
+ "star": "*",
120
+ }
121
+ normalized = (style or "default").strip().lower().replace("-", "_")
122
+ if normalized not in style_chars:
123
+ raise ValueError(f"unknown government_report bullet style: {style!r}")
124
+ return style_chars[normalized]
125
+ default_chars = ("-", "○", "□", "•")
126
+ return default_chars[level % len(default_chars)]
127
+
128
+
129
+ def _builder_preset(value: str | None) -> _BuilderPreset:
130
+ normalized = (value or "default").strip().lower().replace("-", "_")
131
+ if normalized in {"", "default", "standard", "standard_korean_business"}:
132
+ return _BuilderPreset()
133
+ if normalized in {"government_report", "gov_report", "공문보고서"}:
134
+ return _BuilderPreset(name="government_report")
135
+ raise ValueError(f"unknown builder preset: {value!r}")
136
+
137
+
138
+ def _mm_to_hwp_units(value: float) -> int:
139
+ return round(value * _HWP_UNITS_PER_MM)
140
+
141
+
142
+ def _computed_text(text: str) -> str:
143
+ replace_computed_fields = import_module(
144
+ "hwpx_automation.office.authoring"
145
+ ).replace_computed_fields
146
+ return replace_computed_fields(text)
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class PageSize:
151
+ width_mm: float
152
+ height_mm: float
153
+ orientation: str = "PORTRAIT"
154
+ A4: ClassVar["PageSize"]
155
+
156
+
157
+ PageSize.A4 = PageSize(width_mm=210, height_mm=297)
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class Margins:
162
+ top_mm: float = 20
163
+ right_mm: float = 20
164
+ bottom_mm: float = 20
165
+ left_mm: float = 20
166
+ header_mm: float = 10
167
+ footer_mm: float = 10
168
+ gutter_mm: float = 0
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class Metadata:
173
+ title: str = ""
174
+ author: str = ""
175
+ organization: str = ""
176
+
177
+ def as_dict(self) -> dict[str, str]:
178
+ return {
179
+ "title": self.title,
180
+ "author": self.author,
181
+ "organization": self.organization,
182
+ }
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class Run:
187
+ text: str = ""
188
+ bold: bool = False
189
+ italic: bool = False
190
+ underline: bool = False
191
+ color: str | None = None
192
+ font: str | None = None
193
+ size: int | float | None = None
194
+ highlight: str | None = None
195
+ strike: bool = False
196
+
197
+
198
+ @dataclass(frozen=True)
199
+ class Paragraph:
200
+ text: str = ""
201
+ children: Sequence[Run | PageNumber] = field(default_factory=tuple)
202
+ align: str | None = None
203
+ style: str | None = None
204
+
205
+ def lower(self, document: HwpxDocument, *, preset: _BuilderPreset | None = None) -> None:
206
+ style_preset = preset or _BuilderPreset()
207
+ if self.children:
208
+ paragraph = document.add_paragraph("", include_run=False, inherit_style=False)
209
+ for run in self.children:
210
+ if isinstance(run, PageNumber):
211
+ raise ValueError("PageNumber is only supported in Header/Footer content")
212
+ paragraph.add_run(_computed_text(run.text), **style_preset.run_style(run))
213
+ return
214
+ style_kwargs = style_preset.paragraph_style(self.style)
215
+ if style_kwargs is None:
216
+ document.add_paragraph(_computed_text(self.text), inherit_style=False)
217
+ return
218
+ char_pr_id = document.ensure_run_style(**style_kwargs)
219
+ document.add_paragraph(
220
+ _computed_text(self.text),
221
+ char_pr_id_ref=char_pr_id,
222
+ inherit_style=False,
223
+ )
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class PageBreak:
228
+ def lower(self, document: HwpxDocument) -> None:
229
+ document.add_paragraph("", pageBreak="1", inherit_style=False)
230
+
231
+
232
+ @dataclass(frozen=True)
233
+ class Toc:
234
+ title: str = "목차"
235
+ entries: Sequence[Mapping[str, Any]] = field(default_factory=tuple)
236
+
237
+ def lower(
238
+ self,
239
+ document: HwpxDocument,
240
+ *,
241
+ section_index: int = 0,
242
+ preset: _BuilderPreset | None = None,
243
+ ) -> None:
244
+ style_preset = preset or _BuilderPreset()
245
+ title_style = (
246
+ document.ensure_run_style(**style_preset.heading_style(2))
247
+ if style_preset.is_government_report
248
+ else document.ensure_run_style(bold=True, size=14)
249
+ )
250
+ entry_style = document.ensure_run_style()
251
+ document.add_paragraph(
252
+ _computed_text(self.title),
253
+ section_index=section_index,
254
+ char_pr_id_ref=title_style,
255
+ inherit_style=False,
256
+ )
257
+ for entry in self.entries:
258
+ text = str(entry.get("text") or "").strip()
259
+ if not text:
260
+ continue
261
+ page = str(entry.get("page") or "").strip()
262
+ line = f"{text}\t{page}" if page else text
263
+ document.add_paragraph(
264
+ _computed_text(line),
265
+ section_index=section_index,
266
+ char_pr_id_ref=entry_style,
267
+ inherit_style=False,
268
+ )
269
+
270
+
271
+ @dataclass(frozen=True)
272
+ class NativeToc:
273
+ """Hancom-native TABLEOFCONTENTS field block.
274
+
275
+ Unlike :class:`Toc` (a static ``text\\tpage`` plaintext list), this lowers
276
+ to the measured native field region via
277
+ :func:`hwpx.tools.toc_author.add_native_toc` AFTER the whole document is
278
+ composed: the region is inserted at the paragraph position where this
279
+ block appeared, entries are generated from the document's outline
280
+ (개요-styled) headings, and ``dirty=True`` (default, measured semantics)
281
+ makes Hancom regenerate entries/styles/page numbers on its next open.
282
+
283
+ Composition also enforces the measured ContentsStyles trap first: body
284
+ text on the collected style 0 (바탕글) is routed onto 본문/Body via
285
+ :func:`hwpx.tools.toc_author.ensure_body_styles_not_collected`, or the
286
+ lowering fails loudly rather than emit a TOC that swallows body text.
287
+ """
288
+
289
+ title: str = "<제목 차례>"
290
+ level: int = 2
291
+ leader: int = 3
292
+ hyperlink: bool = True
293
+ dirty: bool = True
294
+
295
+
296
+ def _apply_native_tocs(
297
+ document: HwpxDocument,
298
+ pending: Sequence[tuple[int, NativeToc]],
299
+ ) -> None:
300
+ """Insert deferred native TOC regions into the fully composed document."""
301
+
302
+ if not pending:
303
+ return
304
+ if len(pending) > 1:
305
+ raise ValueError(
306
+ "only one native TOC per document is supported "
307
+ "(the M7 contract measured a single TABLEOFCONTENTS field)"
308
+ )
309
+ from hwpx.tools.toc_author import add_native_toc, ensure_body_styles_not_collected
310
+
311
+ at_index, node = pending[0]
312
+ # Measured trap first: body text must leave the collected style 0 BEFORE
313
+ # the region is inserted, so the region's own style-0 paragraphs (gold
314
+ # contract) are not rerouted.
315
+ ensure_body_styles_not_collected(document)
316
+ add_native_toc(
317
+ document,
318
+ at_index=at_index,
319
+ title=_computed_text(node.title),
320
+ level=node.level,
321
+ leader=node.leader,
322
+ hyperlink=node.hyperlink,
323
+ dirty=node.dirty,
324
+ )
325
+
326
+
327
+ @dataclass(frozen=True)
328
+ class Heading:
329
+ level: int
330
+ text: str
331
+
332
+ def lower(
333
+ self,
334
+ document: HwpxDocument,
335
+ *,
336
+ section_index: int = 0,
337
+ preset: _BuilderPreset | None = None,
338
+ ) -> None:
339
+ if self.level < 1 or self.level > 3:
340
+ raise ValueError("heading level must be between 1 and 3")
341
+ style_preset = preset or _BuilderPreset()
342
+ char_pr_id = document.ensure_run_style(**style_preset.heading_style(self.level))
343
+ document.add_paragraph(
344
+ _computed_text(self.text),
345
+ section_index=section_index,
346
+ char_pr_id_ref=char_pr_id,
347
+ inherit_style=False,
348
+ **cast(Any, _outline_style_refs(document, self.level)),
349
+ )
350
+
351
+
352
+ @dataclass(frozen=True)
353
+ class Bullet:
354
+ items: Sequence[str]
355
+ level: int = 0
356
+ style: str | None = None
357
+
358
+ def lower(
359
+ self,
360
+ document: HwpxDocument,
361
+ *,
362
+ section_index: int = 0,
363
+ preset: _BuilderPreset | None = None,
364
+ ) -> None:
365
+ style_preset = preset or _BuilderPreset()
366
+ level_count = max(self.level + 1, 1)
367
+ levels = [
368
+ {
369
+ "char": style_preset.bullet_char(
370
+ level=index,
371
+ style=self.style if index == self.level else None,
372
+ )
373
+ }
374
+ for index in range(level_count)
375
+ ]
376
+ refs = document.ensure_numbering(
377
+ kind="bullet",
378
+ levels=levels,
379
+ )
380
+ para_pr_id = refs[self.level]
381
+ for item in self.items:
382
+ document.add_paragraph(
383
+ _computed_text(item),
384
+ section_index=section_index,
385
+ para_pr_id_ref=para_pr_id,
386
+ inherit_style=False,
387
+ )
388
+
389
+
390
+ @dataclass(frozen=True)
391
+ class NumberedList:
392
+ items: Sequence[str]
393
+ level: int = 0
394
+
395
+ def lower(self, document: HwpxDocument, *, section_index: int = 0) -> None:
396
+ level_count = max(self.level + 1, 1)
397
+ refs = document.ensure_numbering(kind="number", levels=[{} for _ in range(level_count)])
398
+ para_pr_id = refs[self.level]
399
+ for item in self.items:
400
+ document.add_paragraph(
401
+ _computed_text(item),
402
+ section_index=section_index,
403
+ para_pr_id_ref=para_pr_id,
404
+ inherit_style=False,
405
+ )
406
+
407
+
408
+ @dataclass(frozen=True)
409
+ class Table:
410
+ header: Sequence[str] = field(default_factory=tuple)
411
+ rows: Sequence[Sequence[str]] = field(default_factory=tuple)
412
+ merges: Sequence[str] = field(default_factory=tuple)
413
+ header_shading: str | None = None
414
+ column_widths: Sequence[int | float] = field(default_factory=tuple)
415
+
416
+ def lower(
417
+ self,
418
+ document: HwpxDocument,
419
+ *,
420
+ section_index: int = 0,
421
+ preset: _BuilderPreset | None = None,
422
+ ) -> None:
423
+ table_rows: list[Sequence[str]] = []
424
+ if self.header:
425
+ table_rows.append(self.header)
426
+ table_rows.extend(self.rows)
427
+ if not table_rows:
428
+ raise ValueError("table must contain a header or at least one row")
429
+ column_count = max(len(row) for row in table_rows)
430
+ table = document.add_table(
431
+ len(table_rows),
432
+ column_count,
433
+ section_index=section_index,
434
+ )
435
+ for row_index, row in enumerate(table_rows):
436
+ for col_index, value in enumerate(row):
437
+ table.cell(row_index, col_index).text = _computed_text(str(value))
438
+ for merge in self.merges:
439
+ table.merge_cells(merge)
440
+ if self.header and self.header_shading:
441
+ for col_index in range(column_count):
442
+ table.set_cell_shading(0, col_index, self.header_shading)
443
+ if self.column_widths:
444
+ table.set_column_widths(self.column_widths)
445
+
446
+
447
+ def approval_box(
448
+ *,
449
+ labels: Sequence[str] | None = None,
450
+ approver_rows: int = 2,
451
+ delegated: str | None = None,
452
+ header_shading: str = "EAF1FB",
453
+ ) -> Table:
454
+ """Return a merged approval/sign-off table for official documents."""
455
+
456
+ normalized_labels = tuple(str(label).strip() for label in (labels or ("기안", "검토", "결재", "전결")) if str(label).strip())
457
+ if not normalized_labels:
458
+ normalized_labels = ("기안", "검토", "결재", "전결")
459
+ delegated_label = str(delegated or "").strip()
460
+ if delegated_label and delegated_label not in normalized_labels:
461
+ normalized_labels = (*normalized_labels, delegated_label)
462
+ row_count = max(int(approver_rows), 1)
463
+ rows = tuple(tuple("" for _ in normalized_labels) for _ in range(row_count))
464
+ if row_count < 2:
465
+ merges: tuple[str, ...] = ()
466
+ else:
467
+ merges = tuple(
468
+ f"{_spreadsheet_column_name(index)}2:{_spreadsheet_column_name(index)}{row_count + 1}"
469
+ for index in range(len(normalized_labels))
470
+ )
471
+ return Table(
472
+ header=normalized_labels,
473
+ rows=rows,
474
+ merges=merges,
475
+ header_shading=header_shading,
476
+ column_widths=tuple(1 for _ in normalized_labels),
477
+ )
478
+
479
+
480
+ def _spreadsheet_column_name(index: int) -> str:
481
+ if index < 0:
482
+ raise ValueError("column index must be non-negative")
483
+ value = index + 1
484
+ letters: list[str] = []
485
+ while value:
486
+ value, remainder = divmod(value - 1, 26)
487
+ letters.append(chr(ord("A") + remainder))
488
+ return "".join(reversed(letters))
489
+
490
+
491
+ @dataclass(frozen=True)
492
+ class Image:
493
+ path: str | PathLike[str] | bytes
494
+ width_mm: float | None = None
495
+ align: str | None = None
496
+ caption: str | None = None
497
+ image_format: str | None = None
498
+
499
+ def lower(
500
+ self,
501
+ document: HwpxDocument,
502
+ *,
503
+ section_index: int = 0,
504
+ preset: _BuilderPreset | None = None,
505
+ ) -> None:
506
+ if isinstance(self.path, bytes):
507
+ image_data = self.path
508
+ image_format = self.image_format or "png"
509
+ else:
510
+ image_path = Path(self.path)
511
+ image_data = image_path.read_bytes()
512
+ image_format = self.image_format or image_path.suffix.lstrip(".") or "png"
513
+ document.add_picture(
514
+ image_data,
515
+ image_format,
516
+ width_mm=self.width_mm,
517
+ align=self.align,
518
+ section_index=section_index,
519
+ )
520
+ if self.caption:
521
+ document.add_paragraph(_computed_text(self.caption), section_index=section_index, inherit_style=False)
522
+
523
+
524
+ @dataclass(frozen=True)
525
+ class PageNumber:
526
+ format: str = "page"
527
+
528
+
529
+ @dataclass(frozen=True)
530
+ class Header:
531
+ children: Sequence[Paragraph | PageNumber] = field(default_factory=tuple)
532
+
533
+ def lower(
534
+ self,
535
+ document: HwpxDocument,
536
+ *,
537
+ section_index: int = 0,
538
+ preset: _BuilderPreset | None = None,
539
+ ) -> None:
540
+ document.set_header_content(
541
+ _header_footer_content_specs(self.children, preset=preset),
542
+ section_index=section_index,
543
+ )
544
+
545
+
546
+ @dataclass(frozen=True)
547
+ class Footer:
548
+ children: Sequence[Paragraph | PageNumber] = field(default_factory=tuple)
549
+
550
+ def lower(
551
+ self,
552
+ document: HwpxDocument,
553
+ *,
554
+ section_index: int = 0,
555
+ preset: _BuilderPreset | None = None,
556
+ ) -> None:
557
+ document.set_footer_content(
558
+ _header_footer_content_specs(self.children, preset=preset),
559
+ section_index=section_index,
560
+ )
561
+
562
+
563
+ def _run_content_spec(run: Run, *, preset: _BuilderPreset | None = None) -> dict[str, object]:
564
+ style_preset = preset or _BuilderPreset()
565
+ style = style_preset.run_style(run)
566
+ return {
567
+ "type": "run",
568
+ "text": _computed_text(run.text),
569
+ "bold": style["bold"],
570
+ "italic": style["italic"],
571
+ "underline": style["underline"],
572
+ "color": style["color"],
573
+ "font": style["font"],
574
+ "size": style["size"],
575
+ "highlight": style["highlight"],
576
+ "strike": run.strike,
577
+ }
578
+
579
+
580
+ def _page_number_content_spec(page_number: PageNumber) -> dict[str, object]:
581
+ return {"type": "page_number", "format": page_number.format}
582
+
583
+
584
+ def _paragraph_content_spec(
585
+ paragraph: Paragraph,
586
+ *,
587
+ preset: _BuilderPreset | None = None,
588
+ ) -> dict[str, object]:
589
+ if paragraph.children:
590
+ children: list[dict[str, object]] = []
591
+ for child in paragraph.children:
592
+ if isinstance(child, Run):
593
+ children.append(_run_content_spec(child, preset=preset))
594
+ continue
595
+ if isinstance(child, PageNumber):
596
+ children.append(_page_number_content_spec(child))
597
+ continue
598
+ raise ValueError(f"unsupported header/footer paragraph child: {type(child).__name__}")
599
+ else:
600
+ children = [{"type": "run", "text": _computed_text(paragraph.text)}]
601
+ spec: dict[str, object] = {"children": children}
602
+ if paragraph.align:
603
+ spec["align"] = paragraph.align
604
+ return spec
605
+
606
+
607
+ def _header_footer_content_specs(
608
+ children: Sequence[Paragraph | PageNumber],
609
+ *,
610
+ preset: _BuilderPreset | None = None,
611
+ ) -> list[dict[str, object]]:
612
+ specs: list[dict[str, object]] = []
613
+ for child in children:
614
+ if isinstance(child, Paragraph):
615
+ specs.append(_paragraph_content_spec(child, preset=preset))
616
+ continue
617
+ if isinstance(child, PageNumber):
618
+ specs.append({"children": [_page_number_content_spec(child)]})
619
+ continue
620
+ raise ValueError(f"unsupported header/footer child: {type(child).__name__}")
621
+ return specs
622
+
623
+
624
+ def _children_contain_page_number(children: Sequence[Paragraph | PageNumber]) -> bool:
625
+ for child in children:
626
+ if isinstance(child, PageNumber):
627
+ return True
628
+ if isinstance(child, Paragraph) and any(isinstance(grandchild, PageNumber) for grandchild in child.children):
629
+ return True
630
+ return False
631
+
632
+
633
+ def _run_is_rich(run: Run) -> bool:
634
+ return any(
635
+ (
636
+ run.bold,
637
+ run.italic,
638
+ run.underline,
639
+ run.color,
640
+ run.font,
641
+ run.size,
642
+ run.highlight,
643
+ run.strike,
644
+ )
645
+ )
646
+
647
+
648
+ def _children_contain_rich_run(children: Sequence[Paragraph | PageNumber]) -> bool:
649
+ for child in children:
650
+ if not isinstance(child, Paragraph):
651
+ continue
652
+ if any(isinstance(grandchild, Run) and _run_is_rich(grandchild) for grandchild in child.children):
653
+ return True
654
+ return False
655
+
656
+
657
+ def _section_feature_flags(section: "Section") -> dict[str, bool]:
658
+ flags = {
659
+ "metadata": False,
660
+ "page_setup": section.page is not None or section.margins is not None,
661
+ "header_footer": section.header is not None or section.footer is not None,
662
+ "page_number": False,
663
+ "heading": False,
664
+ "rich_run": False,
665
+ "list": False,
666
+ "table": False,
667
+ "image": False,
668
+ "toc": False,
669
+ "page_break": False,
670
+ }
671
+ if section.header is not None and _children_contain_page_number(section.header.children):
672
+ flags["page_number"] = True
673
+ if section.footer is not None and _children_contain_page_number(section.footer.children):
674
+ flags["page_number"] = True
675
+ if section.header is not None and _children_contain_rich_run(section.header.children):
676
+ flags["rich_run"] = True
677
+ if section.footer is not None and _children_contain_rich_run(section.footer.children):
678
+ flags["rich_run"] = True
679
+ for child in section.children:
680
+ if isinstance(child, Heading):
681
+ flags["heading"] = True
682
+ elif isinstance(child, Paragraph):
683
+ if any(isinstance(run, Run) and _run_is_rich(run) for run in child.children):
684
+ flags["rich_run"] = True
685
+ elif isinstance(child, (Bullet, NumberedList)):
686
+ flags["list"] = True
687
+ elif isinstance(child, Table):
688
+ flags["table"] = True
689
+ elif isinstance(child, Image):
690
+ flags["image"] = True
691
+ elif isinstance(child, (Toc, NativeToc)):
692
+ flags["toc"] = True
693
+ elif isinstance(child, PageBreak):
694
+ flags["page_break"] = True
695
+ return flags
696
+
697
+
698
+ def _merge_flags(*flag_sets: dict[str, bool]) -> dict[str, bool]:
699
+ merged: dict[str, bool] = {}
700
+ for flags in flag_sets:
701
+ for key, value in flags.items():
702
+ merged[key] = merged.get(key, False) or value
703
+ return merged
704
+
705
+
706
+ def _hard_gates(
707
+ package_report: object,
708
+ document_report: object,
709
+ reopen_report: ReopenReport,
710
+ editor_open_safety_report: object | None = None,
711
+ ) -> dict[str, str]:
712
+ document_warnings = getattr(document_report, "warnings", ())
713
+ editor_open_safety_ok = (
714
+ True
715
+ if editor_open_safety_report is None
716
+ else bool(getattr(editor_open_safety_report, "ok", False))
717
+ )
718
+ return {
719
+ "package_validation": "pass" if getattr(package_report, "ok", False) else "fail",
720
+ "document_errors": "pass" if getattr(document_report, "ok", False) else "fail",
721
+ "schema_lint": "warning" if document_warnings else "pass",
722
+ "reopen": "pass" if reopen_report.ok else "fail",
723
+ "editor_open_safety": "pass" if editor_open_safety_ok else "fail",
724
+ "id_integrity": "unavailable",
725
+ }
726
+
727
+
728
+ @dataclass(frozen=True)
729
+ class Section:
730
+ children: Sequence[
731
+ Heading | Paragraph | Bullet | NumberedList | Table | Image | Toc | NativeToc | PageBreak
732
+ ] = field(default_factory=tuple)
733
+ page: PageSize | None = None
734
+ margins: Margins | None = None
735
+ header: Header | None = None
736
+ footer: Footer | None = None
737
+
738
+ def lower(
739
+ self,
740
+ document: HwpxDocument,
741
+ *,
742
+ section_index: int = 0,
743
+ preset: _BuilderPreset | None = None,
744
+ native_toc_sink: list[tuple[int, NativeToc]] | None = None,
745
+ ) -> None:
746
+ if self.page is not None:
747
+ if self.page == PageSize.A4:
748
+ width, height = _A4_HWP_SIZE
749
+ else:
750
+ width = _mm_to_hwp_units(self.page.width_mm)
751
+ height = _mm_to_hwp_units(self.page.height_mm)
752
+ document.set_page_size(
753
+ width=width,
754
+ height=height,
755
+ orientation=self.page.orientation,
756
+ section_index=section_index,
757
+ )
758
+ if self.margins is not None:
759
+ document.set_page_margins(
760
+ left=_mm_to_hwp_units(self.margins.left_mm),
761
+ right=_mm_to_hwp_units(self.margins.right_mm),
762
+ top=_mm_to_hwp_units(self.margins.top_mm),
763
+ bottom=_mm_to_hwp_units(self.margins.bottom_mm),
764
+ header=_mm_to_hwp_units(self.margins.header_mm),
765
+ footer=_mm_to_hwp_units(self.margins.footer_mm),
766
+ gutter=_mm_to_hwp_units(self.margins.gutter_mm),
767
+ section_index=section_index,
768
+ )
769
+ if self.header is not None:
770
+ self.header.lower(document, section_index=section_index, preset=preset)
771
+ if self.footer is not None:
772
+ self.footer.lower(document, section_index=section_index, preset=preset)
773
+ pending_native_tocs = native_toc_sink if native_toc_sink is not None else []
774
+ for child in self.children:
775
+ if isinstance(child, (Paragraph, PageBreak)):
776
+ if isinstance(child, Paragraph):
777
+ child.lower(document, preset=preset)
778
+ else:
779
+ child.lower(document)
780
+ continue
781
+ if isinstance(child, Heading):
782
+ child.lower(document, section_index=section_index, preset=preset)
783
+ continue
784
+ if isinstance(child, (Bullet, NumberedList)):
785
+ if isinstance(child, Bullet):
786
+ child.lower(document, section_index=section_index, preset=preset)
787
+ else:
788
+ child.lower(document, section_index=section_index)
789
+ continue
790
+ if isinstance(child, Table):
791
+ child.lower(document, section_index=section_index, preset=preset)
792
+ continue
793
+ if isinstance(child, Image):
794
+ child.lower(document, section_index=section_index, preset=preset)
795
+ continue
796
+ if isinstance(child, Toc):
797
+ child.lower(document, section_index=section_index, preset=preset)
798
+ continue
799
+ if isinstance(child, NativeToc):
800
+ if section_index != 0:
801
+ raise ValueError(
802
+ "native TOC is only supported in the first section "
803
+ "(the measured M7 contract inserts into section 0)"
804
+ )
805
+ # Record the paragraph position where the block appeared; the
806
+ # field region is inserted after the whole document is lowered
807
+ # so the entry source (outline headings) is complete.
808
+ pending_native_tocs.append(
809
+ (len(document.oxml.sections[0].paragraphs), child)
810
+ )
811
+ continue
812
+ raise NotImplementedError(f"{type(child).__name__} lowering is not implemented yet")
813
+ if native_toc_sink is None:
814
+ # standalone Section.lower call: resolve deferred TOCs now
815
+ _apply_native_tocs(document, pending_native_tocs)
816
+
817
+
818
+ @dataclass(frozen=True)
819
+ class Document:
820
+ sections: Sequence[Section] = field(default_factory=lambda: (Section(),))
821
+ metadata: Metadata | None = None
822
+ visual_review_required: bool | None = None
823
+ preset: str | None = None
824
+
825
+ def feature_flags(self) -> dict[str, bool]:
826
+ flags = _merge_flags(*(_section_feature_flags(section) for section in self.sections))
827
+ flags["metadata"] = self.metadata is not None
828
+ layout_sensitive = any(
829
+ flags.get(key, False)
830
+ for key in ("header_footer", "page_number", "table", "image", "page_break")
831
+ )
832
+ flags["layout_sensitive"] = layout_sensitive
833
+ return flags
834
+
835
+ def lower(self) -> HwpxDocument:
836
+ document = HwpxDocument.new()
837
+ preset = _builder_preset(self.preset)
838
+ if self.metadata is not None:
839
+ for label, value in (
840
+ ("제목", self.metadata.title),
841
+ ("작성자", self.metadata.author),
842
+ ("기관", self.metadata.organization),
843
+ ):
844
+ if value:
845
+ document.add_paragraph(f"{label}: {value}", inherit_style=False)
846
+ pending_native_tocs: list[tuple[int, NativeToc]] = []
847
+ for index, section in enumerate(self.sections):
848
+ section.lower(
849
+ document,
850
+ section_index=index,
851
+ preset=preset,
852
+ native_toc_sink=pending_native_tocs,
853
+ )
854
+ _apply_native_tocs(document, pending_native_tocs)
855
+ return document
856
+
857
+ def save_to_path(self, path: str | PathLike[str]) -> BuilderSaveReport:
858
+ document = self.lower()
859
+ # Funnel the write through the single SavePipeline and keep its uniform
860
+ # report (plan §2 Phase B). Transparent policy -> behaviour-identical to
861
+ # the prior ``document.save_to_path`` for a from-scratch (new) document.
862
+ visual_complete = document.save_report(path)
863
+ saved_path = Path(path)
864
+ package_report = validate_package(saved_path)
865
+ document_report = validate_document(saved_path)
866
+ editor_open_safety_report = validate_editor_open_safety(saved_path)
867
+ try:
868
+ reopened_document = HwpxDocument.open(saved_path)
869
+ reopen_report = ReopenReport(ok=True, document=reopened_document)
870
+ except Exception as exc: # pragma: no cover - failure is surfaced in report
871
+ reopen_report = ReopenReport(ok=False, error=f"{type(exc).__name__}: {exc}")
872
+ feature_flags = self.feature_flags()
873
+ visual_review_required = (
874
+ self.visual_review_required
875
+ if self.visual_review_required is not None
876
+ else feature_flags["layout_sensitive"]
877
+ )
878
+ report = BuilderSaveReport(
879
+ path=path,
880
+ validate_package=package_report,
881
+ validate_document=document_report,
882
+ reopened=reopen_report,
883
+ metadata=self.metadata.as_dict() if self.metadata is not None else {},
884
+ hard_gates=_hard_gates(
885
+ package_report,
886
+ document_report,
887
+ reopen_report,
888
+ editor_open_safety_report,
889
+ ),
890
+ visual_review_required=visual_review_required,
891
+ feature_flags=feature_flags,
892
+ editor_open_safety=editor_open_safety_report,
893
+ visual_complete=visual_complete,
894
+ )
895
+ return report
896
+
897
+ def verify(self) -> BuilderVerifyReport:
898
+ """Dry, no-disk pre-write verification of the built document.
899
+
900
+ Lowers the document to bytes in memory and runs the save hard gates
901
+ (package, document, editor-open-safety, reopen) *plus* id-integrity and
902
+ a two-round idempotence check — a strictly stronger gate set than
903
+ :meth:`save_to_path` (whose report leaves id-integrity to the reader and
904
+ does not check idempotence) — without writing any file. Returns a
905
+ compact signal so a caller can branch on ``ok`` and read a
906
+ section/paragraph count before paying to materialize a real save.
907
+
908
+ Serialization itself can fail (e.g. open-safety rejects the output); in
909
+ that case this returns ``ok=False`` with ``serialize_error`` set rather
910
+ than raising, so a caller (fuzz loop, agent) can always branch on the
911
+ result.
912
+
913
+ See :data:`hwpx.builder.report.FIDELITY_CONTRACT` for what a green
914
+ verdict proves vs. does not prove.
915
+ """
916
+
917
+ try:
918
+ lowered = self.lower()
919
+ data = lowered.to_bytes()
920
+ except Exception as exc: # the document cannot even be serialized
921
+ return BuilderVerifyReport(
922
+ ok=False,
923
+ reopen_ok=False,
924
+ package_ok=False,
925
+ document_ok=False,
926
+ editor_open_safety_ok=False,
927
+ id_integrity_ok=False,
928
+ idempotent=False,
929
+ sections_reconciled=False,
930
+ serialize_error=f"{type(exc).__name__}: {exc}",
931
+ )
932
+
933
+ package_report = validate_package(data)
934
+ document_report = validate_document(data)
935
+ editor_open_safety_report = validate_editor_open_safety(data)
936
+
937
+ reopened: HwpxDocument | None = None
938
+ reopen_error: str | None = None
939
+ try:
940
+ reopened = HwpxDocument.open(data)
941
+ except Exception as exc: # surfaced in the report rather than raised
942
+ reopen_error = f"{type(exc).__name__}: {exc}"
943
+
944
+ id_integrity = (
945
+ check_id_integrity(reopened) if reopened is not None else None
946
+ )
947
+
948
+ # Fixed-point check on the EXACT bytes the gates above validated (gen-1)
949
+ # vs. their reopen-and-resave (gen-2), so the idempotence verdict refers
950
+ # to the bytes we would actually write, not a later generation.
951
+ idempotence: IdempotenceReport | None = None
952
+ serialize_error: str | None = None
953
+ try:
954
+ idempotence = check_idempotent_pair(data, HwpxDocument.open(data).to_bytes())
955
+ except Exception as exc:
956
+ serialize_error = f"{type(exc).__name__}: {exc}"
957
+
958
+ # Output-vs-intent: produced section parts must match the source model.
959
+ reconcile = reconcile_package_with_document(data, lowered)
960
+
961
+ package_ok = bool(getattr(package_report, "ok", False))
962
+ document_ok = bool(getattr(document_report, "ok", False))
963
+ editor_open_safety_ok = bool(getattr(editor_open_safety_report, "ok", False))
964
+ id_integrity_ok = bool(getattr(id_integrity, "ok", False))
965
+ idempotent = bool(idempotence is not None and idempotence.ok)
966
+ reopen_ok = reopened is not None
967
+ section_count = len(reopened.sections) if reopened is not None else 0
968
+ paragraph_count = len(reopened.paragraphs) if reopened is not None else 0
969
+
970
+ ok = (
971
+ package_ok
972
+ and document_ok
973
+ and editor_open_safety_ok
974
+ and id_integrity_ok
975
+ and reopen_ok
976
+ and idempotent
977
+ and reconcile.ok
978
+ )
979
+
980
+ return BuilderVerifyReport(
981
+ ok=ok,
982
+ reopen_ok=reopen_ok,
983
+ package_ok=package_ok,
984
+ document_ok=document_ok,
985
+ editor_open_safety_ok=editor_open_safety_ok,
986
+ id_integrity_ok=id_integrity_ok,
987
+ idempotent=idempotent,
988
+ sections_reconciled=reconcile.ok,
989
+ section_count=section_count,
990
+ paragraph_count=paragraph_count,
991
+ byte_length=len(data),
992
+ reopen_error=reopen_error,
993
+ serialize_error=serialize_error,
994
+ idempotence=idempotence,
995
+ reconcile=reconcile,
996
+ )