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,3542 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Declarative document-plan authoring for agent-generated HWPX files."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from ast import literal_eval as _literal
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any, Literal, Mapping, cast, overload
11
+
12
+ from hwpx.document import HwpxDocument
13
+ from hwpx.oxml.namespaces import HP as _HP
14
+ from hwpx.tools.package_validator import validate_package
15
+ from hwpx.tools.report_utils import (
16
+ calculate_age,
17
+ calculate_ratios,
18
+ format_delta,
19
+ format_delta_percent,
20
+ format_krw_hangul,
21
+ format_number_commas,
22
+ normalize_korean_date,
23
+ )
24
+ from hwpx.tools.table_cleanup import normalize_cell_text
25
+
26
+ from .advanced_generators import build_image_grid
27
+ from .builder import (
28
+ Bullet as BuilderBullet,
29
+ )
30
+ from .builder import (
31
+ Document as BuilderDocument,
32
+ )
33
+ from .builder import (
34
+ Footer as BuilderFooter,
35
+ )
36
+ from .builder import (
37
+ Header as BuilderHeader,
38
+ )
39
+ from .builder import (
40
+ Heading as BuilderHeading,
41
+ )
42
+ from .builder import (
43
+ Image as BuilderImage,
44
+ )
45
+ from .builder import (
46
+ Margins as BuilderMargins,
47
+ )
48
+ from .builder import (
49
+ Metadata as BuilderMetadata,
50
+ )
51
+ from .builder import (
52
+ NumberedList as BuilderNumberedList,
53
+ )
54
+ from .builder import (
55
+ PageBreak as BuilderPageBreak,
56
+ )
57
+ from .builder import (
58
+ PageNumber as BuilderPageNumber,
59
+ )
60
+ from .builder import (
61
+ PageSize as BuilderPageSize,
62
+ )
63
+ from .builder import (
64
+ Paragraph as BuilderParagraph,
65
+ )
66
+ from .builder import (
67
+ Run as BuilderRun,
68
+ )
69
+ from .builder import (
70
+ Section as BuilderSection,
71
+ )
72
+ from .builder import (
73
+ Table as BuilderTable,
74
+ )
75
+ from .builder import (
76
+ approval_box as BuilderApprovalBox,
77
+ )
78
+ from .builder.core import NativeToc as BuilderNativeToc
79
+ from .builder.core import Toc as BuilderToc
80
+
81
+ DOCUMENT_PLAN_SCHEMA_VERSION = "hwpx.document_plan.v1"
82
+ DOCUMENT_PLAN_V2_SCHEMA_VERSION = "hwpx.document_plan.v2"
83
+ AUTHORING_REPORT_VERSION = "hwpx-authoring-quality-v1"
84
+ OPERATING_PLAN_QUALITY_VERSION = "operating-plan-quality-v1"
85
+ DEFAULT_STYLE_PRESET = "standard_korean_business"
86
+ _DEFAULT_TABLE_WIDTH = 45_000 # ~158.7mm (HWPUNIT): fits A4(210mm) content at 25mm margins (~160mm). 48000(~169mm) overflowed the right margin in Hancom.
87
+ _METADATA_LABELS = {
88
+ "organization": "기관",
89
+ "author": "작성자",
90
+ "date": "작성일",
91
+ "document_type": "문서 유형",
92
+ }
93
+ _SUPPORTED_BLOCK_TYPES = frozenset(
94
+ {"heading", "paragraph", "bullets", "table", "page_break", "memo"}
95
+ )
96
+ _SUPPORTED_STYLE_TOKENS = frozenset(
97
+ {"body", "title", "subtitle", "heading", "bullet", "table_header", "table_cell"}
98
+ )
99
+ _SUPPORTED_TABLE_PROFILES = frozenset({"government"})
100
+ _DEFAULT_PAGE_MARGIN_MM = 25
101
+ _TABLE_BORDER_COLOR = "#BFBFBF"
102
+ _TABLE_HEADER_FILL = "#F2F2F2"
103
+ _TABLE_CELL_MARGIN = "425"
104
+ _BOOLEAN_QUALITY_GATES = frozenset(
105
+ {"validatePackage", "validateDocument", "reopen", "visualReviewRequired"}
106
+ )
107
+ _INTEGER_QUALITY_GATES = frozenset({"minNonEmptyParagraphs", "minTableCount"})
108
+ _LIST_QUALITY_GATES = frozenset({"requiredText"})
109
+ _COMPUTED_FIELD_RE = re.compile(r"\{\{\s*(.*?)\s*\}\}")
110
+ _COMPUTED_CALL_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)$", re.DOTALL)
111
+
112
+
113
+ @dataclass(slots=True)
114
+ class DocumentBlock:
115
+ """A normalized block in a declarative HWPX document plan."""
116
+
117
+ type: str
118
+ data: dict[str, Any] = field(default_factory=dict)
119
+
120
+
121
+ @dataclass(slots=True)
122
+ class DocumentPlan:
123
+ """Normalized representation of ``hwpx.document_plan.v1``."""
124
+
125
+ title: str
126
+ subtitle: str = ""
127
+ metadata: dict[str, str] = field(default_factory=dict)
128
+ blocks: list[DocumentBlock] = field(default_factory=list)
129
+ style_preset: str = DEFAULT_STYLE_PRESET
130
+ quality_gates: dict[str, Any] = field(default_factory=dict)
131
+ schema_version: str = DOCUMENT_PLAN_SCHEMA_VERSION
132
+ builder_document: BuilderDocument | None = None
133
+
134
+ def to_dict(self) -> dict[str, Any]:
135
+ """Return a JSON-serializable representation of this plan."""
136
+
137
+ return {
138
+ "schemaVersion": self.schema_version,
139
+ "title": self.title,
140
+ "subtitle": self.subtitle,
141
+ "metadata": dict(self.metadata),
142
+ "stylePreset": self.style_preset,
143
+ "blocks": [
144
+ {"type": block.type, **dict(block.data)}
145
+ for block in self.blocks
146
+ ],
147
+ "qualityGates": dict(self.quality_gates),
148
+ }
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class PlanValidationIssue:
153
+ """Machine-readable issue from validating a declarative document plan."""
154
+
155
+ code: str
156
+ path: str
157
+ message: str
158
+ severity: str = "error"
159
+ suggestion: str = ""
160
+
161
+ def to_dict(self) -> dict[str, Any]:
162
+ """Return a JSON-serializable representation of this issue."""
163
+
164
+ return {
165
+ "code": self.code,
166
+ "path": self.path,
167
+ "message": self.message,
168
+ "severity": self.severity,
169
+ "suggestion": self.suggestion,
170
+ }
171
+
172
+ def __str__(self) -> str: # pragma: no cover - human readable helper
173
+ return f"{self.path}: {self.message}"
174
+
175
+
176
+ @dataclass(frozen=True, slots=True)
177
+ class PlanValidationReport:
178
+ """Validation result for a declarative document plan."""
179
+
180
+ ok: bool
181
+ errors: tuple[str, ...] = ()
182
+ warnings: tuple[str, ...] = ()
183
+ schema_version: str | None = None
184
+ issues: tuple[PlanValidationIssue, ...] = ()
185
+
186
+ def to_dict(self) -> dict[str, Any]:
187
+ """Return a JSON-serializable report."""
188
+
189
+ issues = _report_plan_issues(self)
190
+ return {
191
+ "ok": self.ok,
192
+ "schemaVersion": self.schema_version,
193
+ "errors": list(self.errors),
194
+ "warnings": list(self.warnings),
195
+ "issues": [issue.to_dict() for issue in issues],
196
+ "repairHints": _plan_repair_hints(issues),
197
+ }
198
+
199
+
200
+ @dataclass(frozen=True, slots=True)
201
+ class DocumentStylePreset:
202
+ """Semantic style-token preset for generated HWPX documents."""
203
+
204
+ name: str = DEFAULT_STYLE_PRESET
205
+ title_bold: bool = True
206
+ subtitle_italic: bool = False
207
+ heading_bold: bool = True
208
+ heading_underline: bool = False
209
+ table_header_bold: bool = True
210
+ title_size: int = 20
211
+ subtitle_size: int = 12
212
+ heading_size: int = 14
213
+ body_size: int = 11
214
+ meta_size: int = 10
215
+ font: str = "함초롬돋움"
216
+ title_color: str | None = "#1F3864"
217
+ heading_color: str | None = "#1F3864"
218
+ subtitle_color: str | None = "#595959"
219
+ meta_color: str | None = "#595959"
220
+ title_rule: bool = True
221
+ heading_rule: bool = True
222
+ rule_color: str = "#BFBFBF"
223
+
224
+ def ensure_tokens(self, document: HwpxDocument) -> dict[str, str]:
225
+ """Create/reuse run styles and return semantic token IDs."""
226
+
227
+ return {
228
+ "title": document.ensure_run_style(
229
+ bold=self.title_bold,
230
+ size=self.title_size,
231
+ font=self.font,
232
+ color=self.title_color,
233
+ ),
234
+ "subtitle": document.ensure_run_style(
235
+ italic=self.subtitle_italic,
236
+ size=self.subtitle_size,
237
+ font=self.font,
238
+ color=self.subtitle_color,
239
+ ),
240
+ "heading": document.ensure_run_style(
241
+ bold=self.heading_bold,
242
+ underline=self.heading_underline,
243
+ size=self.heading_size,
244
+ font=self.font,
245
+ color=self.heading_color,
246
+ ),
247
+ "body": document.ensure_run_style(size=self.body_size, font=self.font),
248
+ "bullet": document.ensure_run_style(size=self.body_size, font=self.font),
249
+ "meta": document.ensure_run_style(
250
+ size=self.meta_size,
251
+ font=self.font,
252
+ color=self.meta_color,
253
+ ),
254
+ "table_header": document.ensure_run_style(
255
+ bold=self.table_header_bold,
256
+ size=self.body_size,
257
+ font=self.font,
258
+ ),
259
+ "table_cell": document.ensure_run_style(
260
+ size=self.body_size,
261
+ font=self.font,
262
+ ),
263
+ }
264
+
265
+
266
+ def _outline_style_refs(document: HwpxDocument, level: int) -> dict[str, str | int]:
267
+ """Return paragraph style refs for a HWP outline heading level, if available."""
268
+
269
+ safe_level = min(10, max(1, int(level)))
270
+ for style in document.styles.values():
271
+ name = str(style.name or "")
272
+ eng_name = str(style.eng_name or "")
273
+ if name == f"개요 {safe_level}" or eng_name == f"Outline {safe_level}":
274
+ refs: dict[str, str | int] = {}
275
+ style_id = style.raw_id if style.raw_id is not None else style.id
276
+ if style_id is None:
277
+ continue
278
+ refs["style_id_ref"] = style_id
279
+ if style.para_pr_id_ref is not None:
280
+ refs["para_pr_id_ref"] = int(style.para_pr_id_ref)
281
+ return refs
282
+ return {}
283
+
284
+
285
+ def _plan_issue(
286
+ code: str,
287
+ path: str,
288
+ message: str,
289
+ *,
290
+ severity: str = "error",
291
+ suggestion: str = "",
292
+ ) -> PlanValidationIssue:
293
+ return PlanValidationIssue(
294
+ code=code,
295
+ path=path,
296
+ message=message,
297
+ severity=severity,
298
+ suggestion=suggestion,
299
+ )
300
+
301
+
302
+ _PLAN_FAMILY_PREFIX = "hwpx.document_plan.v"
303
+
304
+
305
+ def _is_forward_plan_version(version: str) -> bool:
306
+ """True if *version* is a newer same-family plan schema (forward-compat).
307
+
308
+ e.g. ``hwpx.document_plan.v3`` when the latest known is v2 — validate
309
+ best-effort with a warning rather than hard-rejecting.
310
+ """
311
+ if not version.startswith(_PLAN_FAMILY_PREFIX):
312
+ return False
313
+ suffix = version[len(_PLAN_FAMILY_PREFIX):]
314
+ if not suffix.isdigit():
315
+ return False
316
+ latest_known = max(
317
+ int(DOCUMENT_PLAN_SCHEMA_VERSION.rsplit("v", 1)[-1]),
318
+ int(DOCUMENT_PLAN_V2_SCHEMA_VERSION.rsplit("v", 1)[-1]),
319
+ )
320
+ return int(suffix) > latest_known
321
+
322
+
323
+ def _plan_validation_report(
324
+ issues: list[PlanValidationIssue],
325
+ *,
326
+ schema_version: str | None,
327
+ ) -> PlanValidationReport:
328
+ errors = tuple(issue.message for issue in issues if issue.severity == "error")
329
+ warnings = tuple(issue.message for issue in issues if issue.severity != "error")
330
+ return PlanValidationReport(
331
+ ok=not errors,
332
+ errors=errors,
333
+ warnings=warnings,
334
+ schema_version=schema_version,
335
+ issues=tuple(issues),
336
+ )
337
+
338
+
339
+ _COMPUTED_FUNCTIONS = {
340
+ "krw_hangul": format_krw_hangul,
341
+ "commas": format_number_commas,
342
+ "age": calculate_age,
343
+ "delta": format_delta,
344
+ "delta_percent": format_delta_percent,
345
+ "ratio": calculate_ratios,
346
+ "date": normalize_korean_date,
347
+ }
348
+
349
+
350
+ class _ComputedFieldError(ValueError):
351
+ def __init__(self, code: str, message: str) -> None:
352
+ super().__init__(message)
353
+ self.code = code
354
+
355
+
356
+ def replace_computed_fields(text: str) -> str:
357
+ """Replace safe ``{{ function(args) }}`` report utility placeholders."""
358
+
359
+ def replacement(match: re.Match[str]) -> str:
360
+ return _evaluate_computed_field(match.group(1))
361
+
362
+ result = _COMPUTED_FIELD_RE.sub(replacement, text)
363
+ if "{{" in result or "}}" in result:
364
+ raise _ComputedFieldError(
365
+ "invalid_computed_field",
366
+ "computed field marker is malformed or unresolved",
367
+ )
368
+ return result
369
+
370
+
371
+ def _evaluate_computed_field(expression: str) -> str:
372
+ match = _COMPUTED_CALL_RE.match(expression.strip())
373
+ if not match:
374
+ raise _ComputedFieldError(
375
+ "invalid_computed_field",
376
+ f"computed field must be a function call: {expression!r}",
377
+ )
378
+ function_name, raw_args = match.groups()
379
+ function = _COMPUTED_FUNCTIONS.get(function_name)
380
+ if function is None:
381
+ raise _ComputedFieldError(
382
+ "unknown_computed_field",
383
+ f"unknown computed field function: {function_name}",
384
+ )
385
+ args = [_parse_computed_arg(arg) for arg in _split_computed_args(raw_args)]
386
+ try:
387
+ return str(function(*args))
388
+ except Exception as exc:
389
+ raise _ComputedFieldError(
390
+ "invalid_computed_field",
391
+ f"computed field failed: {expression!r}",
392
+ ) from exc
393
+
394
+
395
+ def _split_computed_args(raw_args: str) -> list[str]:
396
+ if not raw_args.strip():
397
+ return []
398
+ args: list[str] = []
399
+ start = 0
400
+ quote: str | None = None
401
+ escaped = False
402
+ for index, char in enumerate(raw_args):
403
+ if escaped:
404
+ escaped = False
405
+ continue
406
+ if char == "\\" and quote:
407
+ escaped = True
408
+ continue
409
+ if quote:
410
+ if char == quote:
411
+ quote = None
412
+ continue
413
+ if char in {"'", '"'}:
414
+ quote = char
415
+ continue
416
+ if char == ",":
417
+ args.append(raw_args[start:index].strip())
418
+ start = index + 1
419
+ if quote:
420
+ raise _ComputedFieldError("invalid_computed_field", "unterminated string argument")
421
+ args.append(raw_args[start:].strip())
422
+ return args
423
+
424
+
425
+ def _parse_computed_arg(token: str) -> object:
426
+ if not token:
427
+ raise _ComputedFieldError("invalid_computed_field", "empty computed field argument")
428
+ if token[0] in {"'", '"'}:
429
+ try:
430
+ value = _literal(token)
431
+ except (SyntaxError, ValueError) as exc:
432
+ raise _ComputedFieldError("invalid_computed_field", "invalid string argument") from exc
433
+ if not isinstance(value, str):
434
+ raise _ComputedFieldError("invalid_computed_field", "only string literals are supported")
435
+ return value
436
+ if re.fullmatch(r"[+-]?\d+", token):
437
+ return int(token)
438
+ if re.fullmatch(r"[+-]?\d+\.\d+", token):
439
+ return float(token)
440
+ raise _ComputedFieldError(
441
+ "invalid_computed_field",
442
+ f"unsupported computed field argument: {token!r}",
443
+ )
444
+
445
+
446
+ def _computed_field_issues(text: Any, *, path: str) -> list[PlanValidationIssue]:
447
+ value = str(text or "")
448
+ if "{{" not in value and "}}" not in value:
449
+ return []
450
+ issues: list[PlanValidationIssue] = []
451
+ for match in _COMPUTED_FIELD_RE.finditer(value):
452
+ try:
453
+ _evaluate_computed_field(match.group(1))
454
+ except _ComputedFieldError as exc:
455
+ issues.append(
456
+ _plan_issue(
457
+ exc.code,
458
+ path,
459
+ str(exc),
460
+ suggestion="Use a supported computed function such as krw_hangul, commas, delta, ratio, or date.",
461
+ )
462
+ )
463
+ residue = _COMPUTED_FIELD_RE.sub("", value)
464
+ if "{{" in residue or "}}" in residue:
465
+ issues.append(
466
+ _plan_issue(
467
+ "invalid_computed_field",
468
+ path,
469
+ "computed field marker is malformed or unresolved",
470
+ suggestion="Use balanced computed field delimiters such as {{ commas(1234) }}.",
471
+ )
472
+ )
473
+ return issues
474
+
475
+
476
+ def _report_plan_issues(report: PlanValidationReport) -> tuple[PlanValidationIssue, ...]:
477
+ if report.issues:
478
+ return report.issues
479
+ issues: list[PlanValidationIssue] = []
480
+ issues.extend(
481
+ _plan_issue("validation_error", "$", message)
482
+ for message in report.errors
483
+ )
484
+ issues.extend(
485
+ _plan_issue("validation_warning", "$", message, severity="warning")
486
+ for message in report.warnings
487
+ )
488
+ return tuple(issues)
489
+
490
+
491
+ def _plan_repair_hints(issues: tuple[PlanValidationIssue, ...]) -> list[dict[str, str]]:
492
+ hints: list[dict[str, str]] = []
493
+ for issue in issues:
494
+ if not issue.suggestion:
495
+ continue
496
+ hints.append(
497
+ {
498
+ "path": issue.path,
499
+ "code": issue.code,
500
+ "action": "fix" if issue.severity == "error" else "review",
501
+ "message": issue.suggestion,
502
+ }
503
+ )
504
+ return hints
505
+
506
+
507
+ DOCUMENT_PLAN_SCHEMA_ID = "https://airmang.github.io/hwpx-plugins/schemas/document_plan.schema.json"
508
+
509
+
510
+ def get_document_plan_schema() -> dict[str, Any]:
511
+ """Return a JSON Schema (draft 2020-12) for the declarative document plan.
512
+
513
+ Built live from the validator's own constants so it never drifts from the
514
+ accepted contract. Usable directly as an LLM Structured-Outputs / external
515
+ JSON-Schema-validation contract: it constrains the envelope (schemaVersion,
516
+ a non-empty ``blocks`` array, each block carrying a known ``type``) while
517
+ leaving block bodies open (``additionalProperties``) for forward-compat.
518
+ """
519
+
520
+ return {
521
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
522
+ "$id": DOCUMENT_PLAN_SCHEMA_ID,
523
+ "title": "HWPX Document Plan",
524
+ "type": "object",
525
+ "required": ["schemaVersion", "blocks"],
526
+ "additionalProperties": True,
527
+ "properties": {
528
+ "schemaVersion": {
529
+ "type": "string",
530
+ "enum": [DOCUMENT_PLAN_SCHEMA_VERSION, DOCUMENT_PLAN_V2_SCHEMA_VERSION],
531
+ "description": "Plan schema version. Newer same-family versions validate best-effort.",
532
+ },
533
+ "title": {"type": "string"},
534
+ "metadata": {"type": "object"},
535
+ "blocks": {
536
+ "type": "array",
537
+ "minItems": 1,
538
+ "items": {
539
+ "type": "object",
540
+ "required": ["type"],
541
+ "additionalProperties": True,
542
+ "properties": {
543
+ "type": {
544
+ "type": "string",
545
+ "enum": sorted(_SUPPORTED_BLOCK_TYPES),
546
+ "description": "Block kind. Body fields depend on the type.",
547
+ }
548
+ },
549
+ },
550
+ },
551
+ },
552
+ }
553
+
554
+
555
+ def validate_document_plan(plan: Mapping[str, Any]) -> PlanValidationReport:
556
+ """Return validation errors for a ``hwpx.document_plan.v1`` mapping."""
557
+
558
+ issues: list[PlanValidationIssue] = []
559
+ schema_version: str | None = None
560
+ if not isinstance(plan, Mapping):
561
+ return _plan_validation_report(
562
+ [
563
+ _plan_issue(
564
+ "plan_not_object",
565
+ "$",
566
+ "document plan must be a mapping",
567
+ suggestion=(
568
+ "Send a JSON object with schemaVersion, title, blocks, "
569
+ "and optional metadata or qualityGates."
570
+ ),
571
+ )
572
+ ],
573
+ schema_version=None,
574
+ )
575
+
576
+ schema_version = str(plan.get("schemaVersion") or "").strip()
577
+ if schema_version not in {DOCUMENT_PLAN_SCHEMA_VERSION, DOCUMENT_PLAN_V2_SCHEMA_VERSION}:
578
+ if _is_forward_plan_version(schema_version):
579
+ # Forward-compat: a newer same-family version warns and validates as
580
+ # the latest known schema (best-effort) instead of hard-rejecting, so
581
+ # a plan emitted against a newer schema still generates. Unknown newer
582
+ # fields are simply ignored by the v2 validator.
583
+ issues.append(
584
+ _plan_issue(
585
+ "forward_schema_version",
586
+ "schemaVersion",
587
+ (
588
+ f"schemaVersion {schema_version!r} is newer than the latest "
589
+ f"known {DOCUMENT_PLAN_V2_SCHEMA_VERSION!r}; validating as "
590
+ "latest known (best-effort)."
591
+ ),
592
+ severity="warning",
593
+ suggestion="Unknown newer fields are ignored; verify the output.",
594
+ )
595
+ )
596
+ v2_report = _validate_document_plan_v2(
597
+ plan, schema_version=DOCUMENT_PLAN_V2_SCHEMA_VERSION
598
+ )
599
+ return _plan_validation_report(
600
+ [*issues, *v2_report.issues],
601
+ schema_version=schema_version,
602
+ )
603
+ issues.append(
604
+ _plan_issue(
605
+ "invalid_schema_version",
606
+ "schemaVersion",
607
+ (
608
+ f"schemaVersion must be {DOCUMENT_PLAN_SCHEMA_VERSION!r} "
609
+ f"or {DOCUMENT_PLAN_V2_SCHEMA_VERSION!r}"
610
+ ),
611
+ suggestion=f"Set schemaVersion to {DOCUMENT_PLAN_SCHEMA_VERSION!r}.",
612
+ )
613
+ )
614
+ elif schema_version == DOCUMENT_PLAN_V2_SCHEMA_VERSION:
615
+ return _validate_document_plan_v2(plan, schema_version=schema_version)
616
+
617
+ title = str(plan.get("title") or "").strip()
618
+ if not title:
619
+ issues.append(
620
+ _plan_issue(
621
+ "empty_title",
622
+ "title",
623
+ "title is empty; generated document will start with blocks",
624
+ severity="warning",
625
+ suggestion="Add a title or accept that generation starts with the first content block.",
626
+ )
627
+ )
628
+
629
+ blocks = plan.get("blocks")
630
+ if not isinstance(blocks, list) or not blocks:
631
+ issues.append(
632
+ _plan_issue(
633
+ "missing_blocks",
634
+ "blocks",
635
+ "blocks must be a non-empty list",
636
+ suggestion=(
637
+ "Add at least one heading, paragraph, bullets, table, "
638
+ "page_break, or memo block."
639
+ ),
640
+ )
641
+ )
642
+ else:
643
+ for index, raw_block in enumerate(blocks):
644
+ issues.extend(_validate_block(raw_block, index=index))
645
+
646
+ metadata = plan.get("metadata", {})
647
+ if metadata is not None and not isinstance(metadata, Mapping):
648
+ issues.append(
649
+ _plan_issue(
650
+ "invalid_metadata",
651
+ "metadata",
652
+ "metadata must be a mapping when provided",
653
+ suggestion="Use an object such as {'organization': '...', 'date': '...'} or omit metadata.",
654
+ )
655
+ )
656
+
657
+ quality_gates = plan.get("qualityGates", {})
658
+ if quality_gates is not None and not isinstance(quality_gates, Mapping):
659
+ issues.append(
660
+ _plan_issue(
661
+ "invalid_quality_gates",
662
+ "qualityGates",
663
+ "qualityGates must be a mapping when provided",
664
+ suggestion="Use a JSON object with boolean quality gates, or omit qualityGates.",
665
+ )
666
+ )
667
+ elif isinstance(quality_gates, Mapping):
668
+ issues.extend(_validate_quality_gates(quality_gates))
669
+
670
+ return _plan_validation_report(issues, schema_version=schema_version or None)
671
+
672
+
673
+ def normalize_document_plan(plan: Mapping[str, Any] | DocumentPlan) -> DocumentPlan:
674
+ """Normalize and validate a document-plan mapping.
675
+
676
+ Raises:
677
+ ValueError: if the plan does not conform to ``hwpx.document_plan.v1``.
678
+ """
679
+
680
+ if isinstance(plan, DocumentPlan):
681
+ return plan
682
+ report = validate_document_plan(plan)
683
+ if not report.ok:
684
+ raise ValueError("; ".join(report.errors))
685
+
686
+ schema_version = str(plan.get("schemaVersion") or "").strip()
687
+ if schema_version == DOCUMENT_PLAN_V2_SCHEMA_VERSION:
688
+ return DocumentPlan(
689
+ schema_version=DOCUMENT_PLAN_V2_SCHEMA_VERSION,
690
+ title="",
691
+ subtitle="",
692
+ metadata={},
693
+ blocks=[],
694
+ style_preset=str(plan.get("stylePreset") or DEFAULT_STYLE_PRESET).strip()
695
+ or DEFAULT_STYLE_PRESET,
696
+ quality_gates=dict(_default_quality_gates() | dict(plan.get("qualityGates") or {})),
697
+ builder_document=_normalize_v2_builder_document(plan),
698
+ )
699
+
700
+ blocks = [
701
+ _normalize_block(raw_block, index=index)
702
+ for index, raw_block in enumerate(plan.get("blocks") or [])
703
+ ]
704
+ return DocumentPlan(
705
+ schema_version=DOCUMENT_PLAN_SCHEMA_VERSION,
706
+ title=str(plan.get("title") or "").strip(),
707
+ subtitle=str(plan.get("subtitle") or "").strip(),
708
+ metadata=_string_mapping(plan.get("metadata") or {}),
709
+ blocks=blocks,
710
+ style_preset=str(plan.get("stylePreset") or DEFAULT_STYLE_PRESET).strip()
711
+ or DEFAULT_STYLE_PRESET,
712
+ quality_gates=dict(_default_quality_gates() | dict(plan.get("qualityGates") or {})),
713
+ )
714
+
715
+
716
+ def _validate_document_plan_v2(
717
+ plan: Mapping[str, Any],
718
+ *,
719
+ schema_version: str,
720
+ ) -> PlanValidationReport:
721
+ issues: list[PlanValidationIssue] = []
722
+ sections = plan.get("sections")
723
+ if not isinstance(sections, list) or not sections:
724
+ issues.append(
725
+ _plan_issue(
726
+ "missing_sections",
727
+ "sections",
728
+ "sections must be a non-empty list",
729
+ suggestion="Add at least one section with a blocks array.",
730
+ )
731
+ )
732
+ return _plan_validation_report(issues, schema_version=schema_version)
733
+
734
+ for section_index, raw_section in enumerate(sections):
735
+ section_path = f"sections[{section_index}]"
736
+ if not isinstance(raw_section, Mapping):
737
+ issues.append(
738
+ _plan_issue(
739
+ "section_not_object",
740
+ section_path,
741
+ f"{section_path} must be a mapping",
742
+ suggestion="Use an object with optional header/footer and a blocks array.",
743
+ )
744
+ )
745
+ continue
746
+ blocks = raw_section.get("blocks", raw_section.get("children"))
747
+ if not isinstance(blocks, list) or not blocks:
748
+ issues.append(
749
+ _plan_issue(
750
+ "missing_section_blocks",
751
+ f"{section_path}.blocks",
752
+ f"{section_path}.blocks must be a non-empty list",
753
+ suggestion="Add builder blocks such as heading, paragraph, table, image, or page_break.",
754
+ )
755
+ )
756
+ continue
757
+ for block_index, raw_block in enumerate(blocks):
758
+ issues.extend(
759
+ _validate_v2_block(
760
+ raw_block,
761
+ path=f"{section_path}.blocks[{block_index}]",
762
+ )
763
+ )
764
+
765
+ metadata = plan.get("metadata", {})
766
+ if metadata is not None and not isinstance(metadata, Mapping):
767
+ issues.append(
768
+ _plan_issue(
769
+ "invalid_metadata",
770
+ "metadata",
771
+ "metadata must be a mapping when provided",
772
+ suggestion="Use an object with title, author, and organization fields or omit metadata.",
773
+ )
774
+ )
775
+ return _plan_validation_report(issues, schema_version=schema_version)
776
+
777
+
778
+ _V2_SUPPORTED_BLOCK_TYPES = {
779
+ "heading",
780
+ "paragraph",
781
+ "bullets",
782
+ "bullet",
783
+ "numbered_list",
784
+ "numberedList",
785
+ "table",
786
+ "image",
787
+ "image_grid",
788
+ "imageGrid",
789
+ "toc",
790
+ "page_break",
791
+ "pageBreak",
792
+ "approval_box",
793
+ "approvalBox",
794
+ }
795
+
796
+
797
+ def _validate_v2_block(raw_block: Any, *, path: str) -> list[PlanValidationIssue]:
798
+ if not isinstance(raw_block, Mapping):
799
+ return [
800
+ _plan_issue(
801
+ "block_not_object",
802
+ path,
803
+ f"{path} must be a mapping",
804
+ suggestion="Replace this block with a JSON object containing a supported builder type.",
805
+ )
806
+ ]
807
+ block_type = str(raw_block.get("type") or "").strip()
808
+ if block_type not in _V2_SUPPORTED_BLOCK_TYPES:
809
+ return [
810
+ _plan_issue(
811
+ "unsupported_block_type",
812
+ f"{path}.type",
813
+ f"{path}.type is unsupported: {block_type!r}",
814
+ suggestion="Use a public builder block type.",
815
+ )
816
+ ]
817
+ required_field_issues = _validate_v2_block_required_fields(raw_block, block_type, path=path)
818
+ if required_field_issues is not None:
819
+ return required_field_issues
820
+ return _v2_block_computed_field_issues(raw_block, block_type, path=path)
821
+
822
+
823
+ def _validate_v2_block_required_fields(
824
+ raw_block: Mapping[str, Any], block_type: str, *, path: str
825
+ ) -> list[PlanValidationIssue] | None:
826
+ """Return issues for a missing required field, or None when the block is complete."""
827
+
828
+ if block_type in {"heading", "image"}:
829
+ return _require_v2_text_field(raw_block, block_type, path=path)
830
+ if block_type in {"image_grid", "imageGrid"}:
831
+ return _require_v2_image_grid_images(raw_block, path=path)
832
+ if block_type in {"bullets", "bullet", "numbered_list", "numberedList"}:
833
+ return _require_v2_list_items(raw_block, path=path)
834
+ if block_type == "table":
835
+ return _require_v2_table_content(raw_block, path=path)
836
+ return None
837
+
838
+
839
+ def _require_v2_text_field(
840
+ raw_block: Mapping[str, Any], block_type: str, *, path: str
841
+ ) -> list[PlanValidationIssue] | None:
842
+ text_key = "text" if block_type == "heading" else "path"
843
+ if not str(raw_block.get(text_key) or "").strip():
844
+ return [
845
+ _plan_issue(
846
+ "missing_text",
847
+ f"{path}.{text_key}",
848
+ f"{path}.{text_key} is required",
849
+ suggestion=f"Add non-empty {text_key}.",
850
+ )
851
+ ]
852
+ return None
853
+
854
+
855
+ def _require_v2_image_grid_images(
856
+ raw_block: Mapping[str, Any], *, path: str
857
+ ) -> list[PlanValidationIssue] | None:
858
+ images = raw_block.get("images")
859
+ if not isinstance(images, list) or not images:
860
+ return [
861
+ _plan_issue(
862
+ "missing_image_grid_images",
863
+ f"{path}.images",
864
+ f"{path}.images must be a non-empty list",
865
+ suggestion="Add image items with path and optional caption fields.",
866
+ )
867
+ ]
868
+ for image_index, image in enumerate(images):
869
+ if not isinstance(image, Mapping) or not str(image.get("path") or "").strip():
870
+ return [
871
+ _plan_issue(
872
+ "missing_image_path",
873
+ f"{path}.images[{image_index}].path",
874
+ f"{path}.images[{image_index}].path is required",
875
+ suggestion="Set a non-empty image path.",
876
+ )
877
+ ]
878
+ return None
879
+
880
+
881
+ def _require_v2_list_items(
882
+ raw_block: Mapping[str, Any], *, path: str
883
+ ) -> list[PlanValidationIssue] | None:
884
+ if not _string_list(raw_block.get("items")):
885
+ return [
886
+ _plan_issue(
887
+ "missing_list_items",
888
+ f"{path}.items",
889
+ f"{path}.items must be a non-empty list",
890
+ suggestion="Add one or more list items.",
891
+ )
892
+ ]
893
+ return None
894
+
895
+
896
+ def _require_v2_table_content(
897
+ raw_block: Mapping[str, Any], *, path: str
898
+ ) -> list[PlanValidationIssue] | None:
899
+ header = raw_block.get("header")
900
+ rows = raw_block.get("rows")
901
+ if not isinstance(header, list) and not isinstance(rows, list):
902
+ return [
903
+ _plan_issue(
904
+ "missing_table_content",
905
+ path,
906
+ f"{path} must define header or rows",
907
+ suggestion="Add a header array or rows array.",
908
+ )
909
+ ]
910
+ return None
911
+
912
+
913
+ def _v2_block_computed_field_issues(
914
+ raw_block: Mapping[str, Any], block_type: str, *, path: str
915
+ ) -> list[PlanValidationIssue]:
916
+ if block_type == "heading":
917
+ return _computed_field_issues(raw_block.get("text"), path=f"{path}.text")
918
+ if block_type == "paragraph":
919
+ return _v2_paragraph_computed_field_issues(raw_block, path=path)
920
+ if block_type in {"bullets", "bullet", "numbered_list", "numberedList"}:
921
+ return _v2_list_computed_field_issues(raw_block, path=path)
922
+ if block_type == "table":
923
+ return _v2_table_computed_field_issues(raw_block, path=path)
924
+ if block_type in {"image_grid", "imageGrid"}:
925
+ return _v2_image_grid_computed_field_issues(raw_block, path=path)
926
+ if block_type in {"approval_box", "approvalBox"}:
927
+ return _v2_approval_box_computed_field_issues(raw_block, path=path)
928
+ if block_type == "toc":
929
+ return _v2_toc_computed_field_issues(raw_block, path=path)
930
+ return []
931
+
932
+
933
+ def _v2_paragraph_computed_field_issues(
934
+ raw_block: Mapping[str, Any], *, path: str
935
+ ) -> list[PlanValidationIssue]:
936
+ issues = _computed_field_issues(raw_block.get("text"), path=f"{path}.text")
937
+ children = raw_block.get("children")
938
+ if children is None:
939
+ children = raw_block.get("runs")
940
+ for child_index, child in enumerate(children or []):
941
+ if isinstance(child, Mapping):
942
+ issues.extend(
943
+ _computed_field_issues(
944
+ child.get("text"),
945
+ path=f"{path}.runs[{child_index}].text",
946
+ )
947
+ )
948
+ return issues
949
+
950
+
951
+ def _v2_list_computed_field_issues(
952
+ raw_block: Mapping[str, Any], *, path: str
953
+ ) -> list[PlanValidationIssue]:
954
+ issues: list[PlanValidationIssue] = []
955
+ for item_index, item in enumerate(_string_list(raw_block.get("items"))):
956
+ issues.extend(_computed_field_issues(item, path=f"{path}.items[{item_index}]"))
957
+ return issues
958
+
959
+
960
+ def _v2_table_computed_field_issues(
961
+ raw_block: Mapping[str, Any], *, path: str
962
+ ) -> list[PlanValidationIssue]:
963
+ issues: list[PlanValidationIssue] = []
964
+ for header_index, header_value in enumerate(raw_block.get("header") or []):
965
+ issues.extend(_computed_field_issues(header_value, path=f"{path}.header[{header_index}]"))
966
+ for row_index, row in enumerate(raw_block.get("rows") or []):
967
+ if isinstance(row, (list, tuple)):
968
+ for col_index, value in enumerate(row):
969
+ issues.extend(_computed_field_issues(value, path=f"{path}.rows[{row_index}][{col_index}]"))
970
+ return issues
971
+
972
+
973
+ def _v2_image_grid_computed_field_issues(
974
+ raw_block: Mapping[str, Any], *, path: str
975
+ ) -> list[PlanValidationIssue]:
976
+ issues: list[PlanValidationIssue] = []
977
+ for image_index, image in enumerate(raw_block.get("images") or []):
978
+ if isinstance(image, Mapping):
979
+ issues.extend(_computed_field_issues(image.get("caption"), path=f"{path}.images[{image_index}].caption"))
980
+ return issues
981
+
982
+
983
+ def _v2_approval_box_computed_field_issues(
984
+ raw_block: Mapping[str, Any], *, path: str
985
+ ) -> list[PlanValidationIssue]:
986
+ issues: list[PlanValidationIssue] = []
987
+ for label_index, label in enumerate(raw_block.get("labels") or []):
988
+ issues.extend(_computed_field_issues(label, path=f"{path}.labels[{label_index}]"))
989
+ issues.extend(_computed_field_issues(raw_block.get("delegated"), path=f"{path}.delegated"))
990
+ return issues
991
+
992
+
993
+ def _v2_toc_computed_field_issues(
994
+ raw_block: Mapping[str, Any], *, path: str
995
+ ) -> list[PlanValidationIssue]:
996
+ issues: list[PlanValidationIssue] = []
997
+ native = raw_block.get("native")
998
+ if native is not None and not isinstance(native, bool):
999
+ issues.append(
1000
+ _plan_issue(
1001
+ "invalid_native_flag",
1002
+ f"{path}.native",
1003
+ f"{path}.native must be a boolean when provided",
1004
+ suggestion=(
1005
+ "Set native to true for a Hancom-native auto TOC "
1006
+ "(entries regenerate on open) or omit it for the static list."
1007
+ ),
1008
+ )
1009
+ )
1010
+ issues.extend(_computed_field_issues(raw_block.get("title"), path=f"{path}.title"))
1011
+ for entry_index, entry in enumerate(raw_block.get("entries") or []):
1012
+ if isinstance(entry, Mapping):
1013
+ issues.extend(_computed_field_issues(entry.get("text"), path=f"{path}.entries[{entry_index}].text"))
1014
+ return issues
1015
+
1016
+
1017
+ # --- document-type -> design profile routing ----------------------------------
1018
+ # Maps a plan's document_type (Korean label or profile id) to a committed
1019
+ # hwpx.design profile. When it resolves, create_document_from_plan composes from
1020
+ # the harvested, Hancom-opens-clean profile skeleton instead of the from-scratch
1021
+ # builder. Unknown types keep the legacy from-scratch path (regression-safe).
1022
+ _DOCTYPE_TO_PROFILE = {
1023
+ "공문": "official_notice",
1024
+ "공문서": "official_notice",
1025
+ "official_notice": "official_notice",
1026
+ "보고서": "report",
1027
+ "report": "report",
1028
+ "government_report": "report",
1029
+ "가정통신문": "home_notice",
1030
+ "home_notice": "home_notice",
1031
+ }
1032
+ _DOCTYPE_METADATA_KEYS = (
1033
+ "document_type",
1034
+ "문서 유형",
1035
+ "문서유형",
1036
+ "문서 종류",
1037
+ "문서종류",
1038
+ "documentType",
1039
+ )
1040
+ # 결문 (closing block) fields in their canonical render order.
1041
+ _GYEOLMUN_FIELDS = (
1042
+ ("issuer", "발신명의"),
1043
+ ("productionNumber", "생산등록번호"),
1044
+ ("enforcementDate", "시행일"),
1045
+ ("disclosure", "공개구분"),
1046
+ )
1047
+
1048
+
1049
+ def _plan_document_type(plan: Any) -> str:
1050
+ """Read the plan's document type from metadata (preferred) or top level."""
1051
+
1052
+ if not isinstance(plan, Mapping):
1053
+ return ""
1054
+ metadata = plan.get("metadata")
1055
+ metadata = metadata if isinstance(metadata, Mapping) else {}
1056
+ for key in _DOCTYPE_METADATA_KEYS:
1057
+ value = metadata.get(key) or plan.get(key)
1058
+ if value:
1059
+ return str(value).strip()
1060
+ return ""
1061
+
1062
+
1063
+ def _resolve_design_profile(plan: Any) -> str | None:
1064
+ """Return a committed design profile id for the plan's document_type, or None."""
1065
+
1066
+ raw = _plan_document_type(plan)
1067
+ if not raw:
1068
+ return None
1069
+ from .design import available_profiles
1070
+
1071
+ profile_id = _DOCTYPE_TO_PROFILE.get(raw)
1072
+ if profile_id and profile_id in available_profiles():
1073
+ return profile_id
1074
+ return None
1075
+
1076
+
1077
+ def _bridge_to_design_plan(plan: Mapping[str, Any], profile_id: str):
1078
+ """Lower a document_plan mapping onto a :class:`hwpx.design.plan.DocumentPlan`.
1079
+
1080
+ Heading level 1 -> ``heading`` role, level >= 2 -> ``subheading``; paragraphs
1081
+ and bullet items -> ``body``; tables -> an ``info`` table block. 결문 메타
1082
+ fields are appended as trailing ``body`` blocks in canonical order (P0 proved
1083
+ these survive a Hancom render).
1084
+ """
1085
+
1086
+ from .design.plan import Block as _Block
1087
+ from .design.plan import DocumentPlan as _DesignPlan
1088
+
1089
+ blocks: list = []
1090
+ for raw in plan.get("blocks") or []:
1091
+ if not isinstance(raw, Mapping):
1092
+ continue
1093
+ block_type = str(raw.get("type") or "paragraph")
1094
+ if block_type == "heading":
1095
+ level = int(raw.get("level") or 1)
1096
+ role: Literal["heading", "subheading"] = (
1097
+ "heading" if level <= 1 else "subheading"
1098
+ )
1099
+ blocks.append(_Block(type="paragraph", role=role, text=str(raw.get("text") or "")))
1100
+ elif block_type == "paragraph":
1101
+ blocks.append(_Block(type="paragraph", role="body", text=str(raw.get("text") or "")))
1102
+ elif block_type == "bullets":
1103
+ for item in raw.get("items") or []:
1104
+ blocks.append(_Block(type="paragraph", role="body", text=str(item)))
1105
+ elif block_type == "table":
1106
+ raw_cols = list(raw.get("columns") or raw.get("header") or [])
1107
+ if raw_cols and isinstance(raw_cols[0], Mapping):
1108
+ # document_plan schema: columns=[{key,label}], rows=[{key: value}]
1109
+ keys = [str(c.get("key") or c.get("label") or "") for c in raw_cols]
1110
+ columns = [str(c.get("label") or c.get("key") or "") for c in raw_cols]
1111
+ rows = []
1112
+ for row in raw.get("rows") or []:
1113
+ if isinstance(row, Mapping):
1114
+ rows.append([str(row.get(k, "")) for k in keys])
1115
+ elif isinstance(row, (list, tuple)):
1116
+ rows.append([str(c) for c in row])
1117
+ else:
1118
+ columns = [str(c) for c in raw_cols]
1119
+ rows = [[str(c) for c in row] for row in (raw.get("rows") or [])]
1120
+ blocks.append(_Block(type="table", role="info", columns=columns, rows=rows))
1121
+ # page_break / memo: no design role -> skipped
1122
+ gyeolmun = plan.get("gyeolmun")
1123
+ if isinstance(gyeolmun, Mapping):
1124
+ for key, label in _GYEOLMUN_FIELDS:
1125
+ value = gyeolmun.get(key)
1126
+ if value:
1127
+ blocks.append(_Block(type="paragraph", role="body", text=f"{label} {value}"))
1128
+ return _DesignPlan(profile=profile_id, title=str(plan.get("title") or ""), blocks=blocks)
1129
+
1130
+
1131
+ def _korean_proofing_status(plan: Any, normalized_plan: "DocumentPlan | None") -> str:
1132
+ """Honest 맞춤법/공공언어 status (Constitution V/IX) — never asserts 'passed'.
1133
+
1134
+ No free offline Korean spell/spacing oracle exists, so the default is
1135
+ ``unverified``. If the plan signals an LLM self-proof pass it is labelled
1136
+ ``llm_proofed_not_oracle_verified`` — proofed, but NOT oracle-verified.
1137
+ """
1138
+
1139
+ metadata: Mapping[str, Any] = {}
1140
+ if isinstance(plan, Mapping) and isinstance(plan.get("metadata"), Mapping):
1141
+ metadata = plan["metadata"]
1142
+ elif normalized_plan is not None:
1143
+ metadata = normalized_plan.metadata
1144
+ signal = str(
1145
+ metadata.get("korean_proofing") or metadata.get("korean_proofing_status") or ""
1146
+ ).strip().lower()
1147
+ if signal in {"llm", "llm_proofed", "llm-proofed", "llm_proofed_not_oracle_verified"}:
1148
+ return "llm_proofed_not_oracle_verified"
1149
+ return "unverified"
1150
+
1151
+
1152
+ def create_document_from_plan(
1153
+ plan: Mapping[str, Any] | DocumentPlan,
1154
+ *,
1155
+ preset: str | DocumentStylePreset | None = None,
1156
+ ) -> HwpxDocument:
1157
+ """Create a formatted HWPX document from a declarative document plan."""
1158
+
1159
+ if isinstance(plan, Mapping):
1160
+ profile_id = _resolve_design_profile(plan)
1161
+ if profile_id is not None:
1162
+ from .design import compose_bytes
1163
+
1164
+ design_plan = _bridge_to_design_plan(plan, profile_id)
1165
+ data, result = compose_bytes(design_plan, production=True)
1166
+ if not result.ok:
1167
+ raise ValueError(
1168
+ f"profile compose failed for {profile_id!r}: {result.errors}"
1169
+ )
1170
+ return HwpxDocument.open(data)
1171
+
1172
+ normalized = normalize_document_plan(plan)
1173
+ if normalized.builder_document is not None:
1174
+ return normalized.builder_document.lower()
1175
+ style_preset = (
1176
+ preset
1177
+ if isinstance(preset, DocumentStylePreset)
1178
+ else DocumentStylePreset(name=str(preset or normalized.style_preset or DEFAULT_STYLE_PRESET))
1179
+ )
1180
+ document = HwpxDocument.new()
1181
+ document.set_page_setup(
1182
+ margins_mm={
1183
+ "left": _DEFAULT_PAGE_MARGIN_MM,
1184
+ "right": _DEFAULT_PAGE_MARGIN_MM,
1185
+ "top": _DEFAULT_PAGE_MARGIN_MM,
1186
+ "bottom": _DEFAULT_PAGE_MARGIN_MM,
1187
+ }
1188
+ )
1189
+ tokens = style_preset.ensure_tokens(document)
1190
+ builder_document = _lower_plan_to_builder_document(normalized)
1191
+
1192
+ if normalized.title:
1193
+ paragraph = document.add_paragraph(
1194
+ normalized.title,
1195
+ char_pr_id_ref=tokens["title"],
1196
+ inherit_style=False,
1197
+ )
1198
+ _format_para(
1199
+ document,
1200
+ paragraph,
1201
+ alignment="center",
1202
+ line_spacing=130,
1203
+ after_pt=2,
1204
+ bottom_border=style_preset.title_rule,
1205
+ border_color=style_preset.rule_color,
1206
+ )
1207
+ if normalized.subtitle:
1208
+ paragraph = document.add_paragraph(
1209
+ normalized.subtitle,
1210
+ char_pr_id_ref=tokens["subtitle"],
1211
+ inherit_style=False,
1212
+ )
1213
+ _format_para(document, paragraph, line_spacing=130, after_pt=10)
1214
+
1215
+ if normalized.metadata:
1216
+ paragraph = document.add_paragraph(
1217
+ "문서 정보",
1218
+ char_pr_id_ref=tokens["heading"],
1219
+ inherit_style=False,
1220
+ )
1221
+ _format_para(
1222
+ document,
1223
+ paragraph,
1224
+ line_spacing=150,
1225
+ before_pt=14,
1226
+ after_pt=4,
1227
+ bottom_border=style_preset.heading_rule,
1228
+ border_color=style_preset.rule_color,
1229
+ )
1230
+ _add_key_value_table(document, normalized.metadata, tokens)
1231
+
1232
+ for block in builder_document.sections[0].children:
1233
+ _render_block(document, block, tokens, style_preset=style_preset)
1234
+
1235
+ return document
1236
+
1237
+
1238
+ def inspect_document_authoring_quality(
1239
+ source: str | Path | HwpxDocument,
1240
+ *,
1241
+ plan: Mapping[str, Any] | DocumentPlan | None = None,
1242
+ quality_profile: str | Mapping[str, Any] | None = None,
1243
+ verify_render: bool = False,
1244
+ ) -> dict[str, Any]:
1245
+ """Return deterministic structural quality evidence for generated HWPX.
1246
+
1247
+ When *verify_render* is true AND a Mac Hancom oracle is reachable, the
1248
+ document is rendered and ``render_checked``/``visual_complete`` become real
1249
+ receipts. Otherwise ``render_checked`` is ``False`` and ``visual_complete``
1250
+ is ``"unverified"`` — never a silent true (Constitution V).
1251
+ """
1252
+
1253
+ normalized_plan: DocumentPlan | None = None
1254
+ plan_validation: dict[str, Any] | None = None
1255
+ if plan is not None:
1256
+ validation = (
1257
+ PlanValidationReport(ok=True, schema_version=DOCUMENT_PLAN_SCHEMA_VERSION)
1258
+ if isinstance(plan, DocumentPlan)
1259
+ else validate_document_plan(plan)
1260
+ )
1261
+ plan_validation = validation.to_dict()
1262
+ if validation.ok:
1263
+ normalized_plan = normalize_document_plan(plan)
1264
+
1265
+ path: Path | None = None
1266
+ close_doc = False
1267
+ if isinstance(source, HwpxDocument):
1268
+ document = source
1269
+ else:
1270
+ path = Path(source)
1271
+ document = HwpxDocument.open(path)
1272
+ close_doc = True
1273
+
1274
+ try:
1275
+ package_payload = document.to_bytes()
1276
+ package_report = validate_package(path if path is not None else package_payload)
1277
+ document_report = document.validate()
1278
+ reopened = _can_reopen(path, package_payload)
1279
+ render_checked = False
1280
+ visual_complete: Any = "unverified"
1281
+ if verify_render:
1282
+ from ..rendering import MacHancomOracle
1283
+
1284
+ _mac = MacHancomOracle()
1285
+ if _mac.available():
1286
+ import tempfile as _tf
1287
+
1288
+ with _tf.TemporaryDirectory() as _tmp:
1289
+ _hwpx = Path(_tmp) / "render_check.hwpx"
1290
+ _hwpx.write_bytes(package_payload)
1291
+ _pdf = Path(_tmp) / "render_check.pdf"
1292
+ _rendered = _mac.render_pdf(str(_hwpx), str(_pdf))
1293
+ if _rendered and Path(_rendered).exists():
1294
+ try:
1295
+ import fitz as _fitz
1296
+
1297
+ _doc = _fitz.open(_rendered)
1298
+ _has_text = any(
1299
+ isinstance(text, str) and bool(text.strip())
1300
+ for pg in _doc
1301
+ for text in (pg.get_text(),)
1302
+ )
1303
+ _doc.close()
1304
+ render_checked = bool(_has_text)
1305
+ visual_complete = render_checked
1306
+ except Exception:
1307
+ render_checked = False
1308
+ visual_complete = "unverified"
1309
+ non_empty_texts = [
1310
+ (paragraph.text or "").strip()
1311
+ for paragraph in document.paragraphs
1312
+ if (paragraph.text or "").strip()
1313
+ ]
1314
+ full_text = document.export_text()
1315
+ table_count = len(_iter_tables(document))
1316
+ page_break_count = sum(
1317
+ 1
1318
+ for paragraph in document.paragraphs
1319
+ if paragraph.element.get("pageBreak") == "1"
1320
+ )
1321
+ gates = (
1322
+ normalized_plan.quality_gates
1323
+ if normalized_plan is not None
1324
+ else _default_quality_gates()
1325
+ )
1326
+ gate_results = _evaluate_quality_gates(
1327
+ gates,
1328
+ package_ok=bool(getattr(package_report, "ok", False)),
1329
+ document_ok=bool(getattr(document_report, "ok", False)),
1330
+ reopened=reopened,
1331
+ non_empty_paragraph_count=len(non_empty_texts),
1332
+ table_count=table_count,
1333
+ full_text=full_text,
1334
+ )
1335
+ gaps = [
1336
+ f"quality gate failed: {name}"
1337
+ for name, passed in gate_results.items()
1338
+ if name != "visualReviewRequired" and not passed
1339
+ ]
1340
+ if plan_validation is not None and not plan_validation["ok"]:
1341
+ gaps.append("document plan validation failed")
1342
+
1343
+ style_usage = _style_usage(document)
1344
+ package_issues = _report_issue_dicts(package_report, kind="package")
1345
+ document_issues = _report_issue_dicts(document_report, kind="schema")
1346
+ recovery = _recovery_summary(
1347
+ plan_validation=plan_validation,
1348
+ package_issues=package_issues,
1349
+ document_issues=document_issues,
1350
+ gate_results=gate_results,
1351
+ )
1352
+ profiles = _profile_reports(
1353
+ document,
1354
+ normalized_plan=normalized_plan,
1355
+ quality_profile=quality_profile,
1356
+ )
1357
+ if (
1358
+ "operating_plan" in profiles
1359
+ and not profiles["operating_plan"].get("pass", False)
1360
+ ):
1361
+ gaps.append("operating plan quality failed")
1362
+
1363
+ document_type = ""
1364
+ if isinstance(plan, Mapping):
1365
+ document_type = _plan_document_type(plan)
1366
+ elif normalized_plan is not None:
1367
+ document_type = str(normalized_plan.metadata.get("document_type", "") or "")
1368
+ gongmun_structure: dict[str, Any] | None = None
1369
+ if _DOCTYPE_TO_PROFILE.get(document_type.strip()) == "official_notice":
1370
+ from ..compliance import (
1371
+ inspect_official_document_style as _gongmun_lint,
1372
+ )
1373
+
1374
+ gongmun_structure = _gongmun_lint(document, document_type="공문")
1375
+ if not gongmun_structure.get("structure_pass", True):
1376
+ gaps.append("공문 structure gate failed")
1377
+ korean_proofing_status = _korean_proofing_status(plan, normalized_plan)
1378
+ return {
1379
+ "report_version": AUTHORING_REPORT_VERSION,
1380
+ "schemaVersion": DOCUMENT_PLAN_SCHEMA_VERSION,
1381
+ "plan_validation": plan_validation,
1382
+ "pass": not gaps,
1383
+ "korean_proofing_status": korean_proofing_status,
1384
+ "gongmun_structure": gongmun_structure,
1385
+ "block_counts": _block_counts(normalized_plan),
1386
+ "document": {
1387
+ "paragraph_count": len(document.paragraphs),
1388
+ "non_empty_paragraph_count": len(non_empty_texts),
1389
+ "table_count": table_count,
1390
+ "page_break_count": page_break_count,
1391
+ },
1392
+ "render_checked": render_checked,
1393
+ "visual_complete": visual_complete,
1394
+ "validation": {
1395
+ "reopened": reopened,
1396
+ "validate_package": {
1397
+ "ok": bool(getattr(package_report, "ok", False)),
1398
+ "errors": _report_errors(package_report),
1399
+ "issues": package_issues,
1400
+ },
1401
+ "validate_document": {
1402
+ "ok": bool(getattr(document_report, "ok", False)),
1403
+ "errors": _report_errors(document_report),
1404
+ "issues": document_issues,
1405
+ },
1406
+ },
1407
+ "content_evidence": _content_evidence(gates, full_text=full_text),
1408
+ "quality_gates": gate_results,
1409
+ "style_token_usage": style_usage,
1410
+ "recovery": recovery,
1411
+ "profiles": profiles,
1412
+ "visual_review_required": bool(gates.get("visualReviewRequired", True)) and not render_checked,
1413
+ "gaps": gaps,
1414
+ }
1415
+ finally:
1416
+ if close_doc:
1417
+ document.close()
1418
+
1419
+
1420
+ def inspect_operating_plan_quality(
1421
+ source: str | Path | HwpxDocument,
1422
+ *,
1423
+ plan: Mapping[str, Any] | DocumentPlan | None = None,
1424
+ profile: Mapping[str, Any] | None = None,
1425
+ ) -> dict[str, Any]:
1426
+ """Return a deterministic operating-plan quality report for generated HWPX."""
1427
+
1428
+ normalized_plan: DocumentPlan | None = None
1429
+ if plan is not None:
1430
+ if isinstance(plan, DocumentPlan):
1431
+ normalized_plan = plan
1432
+ else:
1433
+ validation = validate_document_plan(plan)
1434
+ if validation.ok:
1435
+ normalized_plan = normalize_document_plan(plan)
1436
+
1437
+ path: Path | None = None
1438
+ close_doc = False
1439
+ if isinstance(source, HwpxDocument):
1440
+ document = source
1441
+ else:
1442
+ path = Path(source)
1443
+ document = HwpxDocument.open(path)
1444
+ close_doc = True
1445
+
1446
+ try:
1447
+ return _inspect_operating_plan_quality(
1448
+ document,
1449
+ normalized_plan=normalized_plan,
1450
+ profile=profile,
1451
+ )
1452
+ finally:
1453
+ if close_doc:
1454
+ document.close()
1455
+
1456
+
1457
+ def _validate_block(raw_block: Any, *, index: int) -> list[PlanValidationIssue]:
1458
+ path = f"blocks[{index}]"
1459
+ if not isinstance(raw_block, Mapping):
1460
+ return [
1461
+ _plan_issue(
1462
+ "block_not_object",
1463
+ path,
1464
+ f"{path} must be a mapping",
1465
+ suggestion="Replace this block with a JSON object containing a supported type.",
1466
+ )
1467
+ ]
1468
+
1469
+ issues: list[PlanValidationIssue] = []
1470
+ block_type = str(raw_block.get("type") or "").strip()
1471
+ if block_type not in _SUPPORTED_BLOCK_TYPES:
1472
+ return [
1473
+ _plan_issue(
1474
+ "unsupported_block_type",
1475
+ f"{path}.type",
1476
+ f"{path}.type is unsupported: {block_type!r}",
1477
+ suggestion=(
1478
+ "Use one of: heading, paragraph, bullets, table, "
1479
+ "page_break, memo."
1480
+ ),
1481
+ )
1482
+ ]
1483
+
1484
+ if block_type == "heading":
1485
+ issues.extend(_validate_heading_block(raw_block, path=path))
1486
+ issues.extend(_computed_field_issues(raw_block.get("text"), path=f"{path}.text"))
1487
+ elif block_type == "paragraph":
1488
+ issues.extend(_validate_paragraph_block(raw_block, path=path))
1489
+ issues.extend(_computed_field_issues(raw_block.get("text"), path=f"{path}.text"))
1490
+ for run_index, run in enumerate(raw_block.get("runs") or []):
1491
+ if isinstance(run, Mapping):
1492
+ issues.extend(
1493
+ _computed_field_issues(
1494
+ run.get("text"),
1495
+ path=f"{path}.runs[{run_index}].text",
1496
+ )
1497
+ )
1498
+ elif block_type == "bullets":
1499
+ items = _string_list(raw_block.get("items") or raw_block.get("bullets"))
1500
+ if not items:
1501
+ issues.append(
1502
+ _plan_issue(
1503
+ "missing_bullet_items",
1504
+ f"{path}.items",
1505
+ f"{path}.items must be a non-empty list",
1506
+ suggestion="Add a non-empty items array, or use a paragraph block instead.",
1507
+ )
1508
+ )
1509
+ for item_index, item in enumerate(items):
1510
+ issues.extend(_computed_field_issues(item, path=f"{path}.items[{item_index}]"))
1511
+ elif block_type == "table":
1512
+ column_keys, column_issues = _validate_table_columns(raw_block.get("columns"), path=path)
1513
+ issues.extend(column_issues)
1514
+ issues.extend(_validate_table_rows(raw_block.get("rows"), column_keys, path=path))
1515
+ issues.extend(_computed_field_issues(raw_block.get("caption"), path=f"{path}.caption"))
1516
+ issues.extend(_computed_field_issues(raw_block.get("unit"), path=f"{path}.unit"))
1517
+ table_profile = str(raw_block.get("tableProfile") or "").strip()
1518
+ if table_profile and table_profile not in _SUPPORTED_TABLE_PROFILES:
1519
+ issues.append(
1520
+ _plan_issue(
1521
+ "unknown_table_profile",
1522
+ f"{path}.tableProfile",
1523
+ f"{path}.tableProfile is unknown: {table_profile!r}",
1524
+ severity="warning",
1525
+ suggestion="Use tableProfile='government' or omit tableProfile.",
1526
+ )
1527
+ )
1528
+ for column_index, column in enumerate(raw_block.get("columns") or []):
1529
+ if isinstance(column, Mapping):
1530
+ issues.extend(
1531
+ _computed_field_issues(
1532
+ column.get("label"),
1533
+ path=f"{path}.columns[{column_index}].label",
1534
+ )
1535
+ )
1536
+ for row_index, row in enumerate(raw_block.get("rows") or []):
1537
+ if isinstance(row, Mapping):
1538
+ for key, value in row.items():
1539
+ if isinstance(value, Mapping):
1540
+ value = value.get("text", value.get("value"))
1541
+ issues.extend(
1542
+ _computed_field_issues(
1543
+ value,
1544
+ path=f"{path}.rows[{row_index}].{key}",
1545
+ )
1546
+ )
1547
+ elif block_type == "memo":
1548
+ issues.extend(_validate_required_text_fields(raw_block, path=path, fields=("text", "memo")))
1549
+ issues.extend(_computed_field_issues(raw_block.get("text"), path=f"{path}.text"))
1550
+ issues.extend(_computed_field_issues(raw_block.get("memo"), path=f"{path}.memo"))
1551
+
1552
+ return issues
1553
+
1554
+
1555
+ def _validate_heading_block(raw_block: Mapping[str, Any], *, path: str) -> list[PlanValidationIssue]:
1556
+ issues = _validate_required_text_fields(raw_block, path=path, fields=("text",))
1557
+ if "level" not in raw_block or raw_block.get("level") in (None, ""):
1558
+ return issues
1559
+ try:
1560
+ level = int(cast(Any, raw_block.get("level")))
1561
+ except (TypeError, ValueError):
1562
+ issues.append(
1563
+ _plan_issue(
1564
+ "invalid_heading_level",
1565
+ f"{path}.level",
1566
+ f"{path}.level must be between 1 and 3",
1567
+ suggestion="Use level 1, 2, or 3.",
1568
+ )
1569
+ )
1570
+ return issues
1571
+ if level < 1 or level > 3:
1572
+ issues.append(
1573
+ _plan_issue(
1574
+ "invalid_heading_level",
1575
+ f"{path}.level",
1576
+ f"{path}.level must be between 1 and 3",
1577
+ suggestion="Use level 1, 2, or 3.",
1578
+ )
1579
+ )
1580
+ return issues
1581
+
1582
+
1583
+ def _validate_paragraph_block(raw_block: Mapping[str, Any], *, path: str) -> list[PlanValidationIssue]:
1584
+ issues: list[PlanValidationIssue] = []
1585
+ text = str(raw_block.get("text") or "").strip()
1586
+ runs = raw_block.get("runs")
1587
+ has_rich_runs = False
1588
+ if runs is not None:
1589
+ if not isinstance(runs, list):
1590
+ issues.append(
1591
+ _plan_issue(
1592
+ "invalid_runs",
1593
+ f"{path}.runs",
1594
+ f"{path}.runs must be a list of run objects",
1595
+ suggestion="Use runs=[{'text': '...', 'bold': true, 'color': '#1F3864'}].",
1596
+ )
1597
+ )
1598
+ else:
1599
+ for run_index, run in enumerate(runs):
1600
+ run_path = f"{path}.runs[{run_index}]"
1601
+ if not isinstance(run, Mapping):
1602
+ issues.append(
1603
+ _plan_issue(
1604
+ "invalid_run",
1605
+ run_path,
1606
+ f"{run_path} must be a mapping",
1607
+ suggestion="Use a run object with text and optional bold/color fields.",
1608
+ )
1609
+ )
1610
+ continue
1611
+ if str(run.get("text") or "").strip():
1612
+ has_rich_runs = True
1613
+ if not text and not has_rich_runs:
1614
+ issues.extend(_validate_required_text_fields(raw_block, path=path, fields=("text",)))
1615
+ style = str(raw_block.get("style") or "body").strip() or "body"
1616
+ if style not in _SUPPORTED_STYLE_TOKENS:
1617
+ issues.append(
1618
+ _plan_issue(
1619
+ "unknown_style_token",
1620
+ f"{path}.style",
1621
+ f"{path}.style is unknown: {style!r}",
1622
+ severity="warning",
1623
+ suggestion=(
1624
+ "Use body, title, subtitle, heading, bullet, "
1625
+ "table_header, table_cell, or omit style."
1626
+ ),
1627
+ )
1628
+ )
1629
+ return issues
1630
+
1631
+
1632
+ def _validate_required_text_fields(
1633
+ raw_block: Mapping[str, Any],
1634
+ *,
1635
+ path: str,
1636
+ fields: tuple[str, ...],
1637
+ ) -> list[PlanValidationIssue]:
1638
+ issues: list[PlanValidationIssue] = []
1639
+ for field_name in fields:
1640
+ value = str(raw_block.get(field_name) or "").strip()
1641
+ if value:
1642
+ continue
1643
+ issues.append(
1644
+ _plan_issue(
1645
+ "missing_text",
1646
+ f"{path}.{field_name}",
1647
+ f"{path}.{field_name} is required",
1648
+ suggestion=f"Add non-empty {field_name} text.",
1649
+ )
1650
+ )
1651
+ return issues
1652
+
1653
+
1654
+ def _validate_table_columns(
1655
+ value: Any,
1656
+ *,
1657
+ path: str,
1658
+ ) -> tuple[list[str], list[PlanValidationIssue]]:
1659
+ issues: list[PlanValidationIssue] = []
1660
+ if not isinstance(value, list) or not value:
1661
+ return [], [
1662
+ _plan_issue(
1663
+ "missing_table_columns",
1664
+ f"{path}.columns",
1665
+ f"{path}.columns must be a non-empty list",
1666
+ suggestion="Add column objects with unique key values and optional labels.",
1667
+ )
1668
+ ]
1669
+
1670
+ column_keys: list[str] = []
1671
+ seen: set[str] = set()
1672
+ for col_index, raw_column in enumerate(value):
1673
+ column_path = f"{path}.columns[{col_index}]"
1674
+ if not isinstance(raw_column, Mapping):
1675
+ issues.append(
1676
+ _plan_issue(
1677
+ "invalid_table_column",
1678
+ column_path,
1679
+ f"{column_path} must be a mapping",
1680
+ suggestion="Use an object such as {'key': 'item', 'label': 'Item'}.",
1681
+ )
1682
+ )
1683
+ continue
1684
+
1685
+ key = str(raw_column.get("key") or "").strip()
1686
+ if not key:
1687
+ issues.append(
1688
+ _plan_issue(
1689
+ "missing_table_column_key",
1690
+ f"{column_path}.key",
1691
+ f"{column_path}.key is required",
1692
+ suggestion="Add a stable, unique key such as 'item' or 'amount'.",
1693
+ )
1694
+ )
1695
+ elif key in seen:
1696
+ issues.append(
1697
+ _plan_issue(
1698
+ "duplicate_table_column_key",
1699
+ f"{column_path}.key",
1700
+ f"{path}.columns contains duplicate key: {key!r}",
1701
+ suggestion=f"Rename or remove the duplicate column key {key!r}.",
1702
+ )
1703
+ )
1704
+ else:
1705
+ seen.add(key)
1706
+ column_keys.append(key)
1707
+
1708
+ if "widthWeight" in raw_column:
1709
+ try:
1710
+ width_weight = int(cast(Any, raw_column.get("widthWeight")))
1711
+ except (TypeError, ValueError):
1712
+ width_weight = 0
1713
+ if width_weight <= 0:
1714
+ issues.append(
1715
+ _plan_issue(
1716
+ "invalid_width_weight",
1717
+ f"{column_path}.widthWeight",
1718
+ f"{column_path}.widthWeight should be a positive integer",
1719
+ severity="warning",
1720
+ suggestion="Use a positive integer; the current value will be coerced to 1.",
1721
+ )
1722
+ )
1723
+ return column_keys, issues
1724
+
1725
+
1726
+ def _validate_table_rows(
1727
+ value: Any,
1728
+ column_keys: list[str],
1729
+ *,
1730
+ path: str,
1731
+ ) -> list[PlanValidationIssue]:
1732
+ if not isinstance(value, list) or not value:
1733
+ return [
1734
+ _plan_issue(
1735
+ "missing_table_rows",
1736
+ f"{path}.rows",
1737
+ f"{path}.rows must be a non-empty list",
1738
+ suggestion="Add at least one row object whose keys match the table columns.",
1739
+ )
1740
+ ]
1741
+
1742
+ issues: list[PlanValidationIssue] = []
1743
+ for row_index, raw_row in enumerate(value):
1744
+ row_path = f"{path}.rows[{row_index}]"
1745
+ if not isinstance(raw_row, Mapping):
1746
+ issues.append(
1747
+ _plan_issue(
1748
+ "invalid_table_row",
1749
+ row_path,
1750
+ f"{row_path} must be a mapping",
1751
+ suggestion="Use a row object whose keys match the table columns.",
1752
+ )
1753
+ )
1754
+ continue
1755
+ if not column_keys:
1756
+ continue
1757
+ row_keys = {str(key) for key in raw_row.keys()}
1758
+ missing_keys = [key for key in column_keys if key not in row_keys]
1759
+ if missing_keys:
1760
+ issues.append(
1761
+ _plan_issue(
1762
+ "table_row_missing_cells",
1763
+ row_path,
1764
+ f"{row_path} is missing cells for columns: {', '.join(missing_keys)}",
1765
+ severity="warning",
1766
+ suggestion=(
1767
+ "Add keys "
1768
+ + ", ".join(repr(key) for key in missing_keys)
1769
+ + " or accept blank generated cells."
1770
+ ),
1771
+ )
1772
+ )
1773
+ extra_keys = sorted(row_keys - set(column_keys))
1774
+ if extra_keys:
1775
+ issues.append(
1776
+ _plan_issue(
1777
+ "table_row_extra_cells",
1778
+ row_path,
1779
+ f"{row_path} has cells that do not match columns: {', '.join(extra_keys)}",
1780
+ severity="warning",
1781
+ suggestion=(
1782
+ "Remove ignored keys: "
1783
+ + ", ".join(repr(key) for key in extra_keys)
1784
+ + "."
1785
+ ),
1786
+ )
1787
+ )
1788
+ return issues
1789
+
1790
+
1791
+ def _validate_quality_gates(gates: Mapping[str, Any]) -> list[PlanValidationIssue]:
1792
+ issues: list[PlanValidationIssue] = []
1793
+ for key, value in gates.items():
1794
+ gate = str(key)
1795
+ path = f"qualityGates.{gate}"
1796
+ if gate in _BOOLEAN_QUALITY_GATES:
1797
+ if not isinstance(value, bool):
1798
+ issues.append(
1799
+ _plan_issue(
1800
+ "invalid_quality_gate_type",
1801
+ path,
1802
+ f"{path} must be a boolean",
1803
+ suggestion=f"Use true or false for {gate}.",
1804
+ )
1805
+ )
1806
+ continue
1807
+ if gate in _INTEGER_QUALITY_GATES:
1808
+ try:
1809
+ minimum = int(value)
1810
+ except (TypeError, ValueError):
1811
+ minimum = 0
1812
+ if minimum < 1:
1813
+ issues.append(
1814
+ _plan_issue(
1815
+ "invalid_quality_gate_minimum",
1816
+ path,
1817
+ f"{path} must be a positive integer",
1818
+ suggestion=f"Use a positive integer for {gate}.",
1819
+ )
1820
+ )
1821
+ continue
1822
+ if gate in _LIST_QUALITY_GATES:
1823
+ values = _string_list(value)
1824
+ raw_count = len(value) if isinstance(value, list) else 0
1825
+ if not values or len(values) != raw_count:
1826
+ issues.append(
1827
+ _plan_issue(
1828
+ "invalid_required_text",
1829
+ path,
1830
+ f"{path} must be a non-empty list of non-empty strings",
1831
+ suggestion="Provide required text snippets such as section headings.",
1832
+ )
1833
+ )
1834
+ continue
1835
+ return issues
1836
+
1837
+
1838
+ def _normalize_block(raw_block: Any, *, index: int) -> DocumentBlock:
1839
+ if not isinstance(raw_block, Mapping):
1840
+ raise TypeError(f"blocks[{index}] must be a mapping")
1841
+ block_type = str(raw_block.get("type") or "").strip()
1842
+ if block_type not in {"heading", "paragraph", "bullets", "table", "page_break", "memo"}:
1843
+ raise ValueError(f"blocks[{index}].type is unsupported: {block_type!r}")
1844
+
1845
+ if block_type == "heading":
1846
+ level = _int_value(raw_block.get("level", 1), default=1)
1847
+ if level < 1 or level > 3:
1848
+ raise ValueError(f"blocks[{index}].level must be between 1 and 3")
1849
+ text = _required_text(raw_block, "text", index)
1850
+ return DocumentBlock("heading", {"level": level, "text": replace_computed_fields(text)})
1851
+
1852
+ if block_type == "paragraph":
1853
+ runs = _normalize_paragraph_runs(raw_block.get("runs"), index=index)
1854
+ text = (
1855
+ replace_computed_fields(str(raw_block.get("text") or ""))
1856
+ if runs
1857
+ else replace_computed_fields(_required_text(raw_block, "text", index))
1858
+ )
1859
+ data: dict[str, Any] = {
1860
+ "text": text,
1861
+ "style": str(raw_block.get("style") or "body").strip() or "body",
1862
+ }
1863
+ if runs:
1864
+ data["runs"] = runs
1865
+ return DocumentBlock(
1866
+ "paragraph",
1867
+ data,
1868
+ )
1869
+
1870
+ if block_type == "bullets":
1871
+ items = _string_list(raw_block.get("items") or raw_block.get("bullets"))
1872
+ if not items:
1873
+ raise ValueError(f"blocks[{index}].items must be a non-empty list")
1874
+ return DocumentBlock(
1875
+ "bullets",
1876
+ {"items": [replace_computed_fields(item) for item in items]},
1877
+ )
1878
+
1879
+ if block_type == "table":
1880
+ columns = _normalize_columns(raw_block.get("columns"), index=index)
1881
+ rows = _normalize_rows(raw_block.get("rows"), columns, index=index)
1882
+ caption = replace_computed_fields(normalize_cell_text(raw_block.get("caption")))
1883
+ unit = replace_computed_fields(normalize_cell_text(raw_block.get("unit")))
1884
+ table_profile = str(raw_block.get("tableProfile") or "").strip()
1885
+ columns = [
1886
+ {**column, "label": replace_computed_fields(normalize_cell_text(column["label"]))}
1887
+ for column in columns
1888
+ ]
1889
+ rows = [
1890
+ {key: replace_computed_fields(value) for key, value in row.items()}
1891
+ for row in rows
1892
+ ]
1893
+ table_data: dict[str, Any] = {
1894
+ "caption": caption,
1895
+ "columns": columns,
1896
+ "rows": rows,
1897
+ }
1898
+ if unit:
1899
+ table_data["unit"] = unit
1900
+ if table_profile:
1901
+ table_data["tableProfile"] = table_profile
1902
+ return DocumentBlock("table", table_data)
1903
+
1904
+ if block_type == "memo":
1905
+ return DocumentBlock(
1906
+ "memo",
1907
+ {
1908
+ "text": replace_computed_fields(_required_text(raw_block, "text", index)),
1909
+ "memo": replace_computed_fields(_required_text(raw_block, "memo", index)),
1910
+ },
1911
+ )
1912
+
1913
+ return DocumentBlock("page_break", {})
1914
+
1915
+
1916
+ def _normalize_paragraph_runs(value: Any, *, index: int) -> list[dict[str, Any]]:
1917
+ if value is None:
1918
+ return []
1919
+ if not isinstance(value, list):
1920
+ raise ValueError(f"blocks[{index}].runs must be a list")
1921
+ runs: list[dict[str, Any]] = []
1922
+ for run_index, raw_run in enumerate(value):
1923
+ if not isinstance(raw_run, Mapping):
1924
+ raise ValueError(f"blocks[{index}].runs[{run_index}] must be a mapping")
1925
+ text = replace_computed_fields(str(raw_run.get("text") or ""))
1926
+ if not text:
1927
+ continue
1928
+ run: dict[str, Any] = {"text": text}
1929
+ if "bold" in raw_run:
1930
+ run["bold"] = bool(raw_run.get("bold"))
1931
+ if "color" in raw_run:
1932
+ color = _optional_str(raw_run.get("color"))
1933
+ if color is not None:
1934
+ run["color"] = color
1935
+ runs.append(run)
1936
+ return runs
1937
+
1938
+
1939
+ def _normalize_v2_builder_document(plan: Mapping[str, Any]) -> BuilderDocument:
1940
+ metadata = plan.get("metadata") or {}
1941
+ builder_metadata = None
1942
+ if isinstance(metadata, Mapping):
1943
+ title = str(metadata.get("title") or plan.get("title") or "").strip()
1944
+ author = str(metadata.get("author") or "").strip()
1945
+ organization = str(metadata.get("organization") or "").strip()
1946
+ if title or author or organization:
1947
+ builder_metadata = BuilderMetadata(
1948
+ title=title,
1949
+ author=author,
1950
+ organization=organization,
1951
+ )
1952
+ return BuilderDocument(
1953
+ sections=tuple(
1954
+ _normalize_v2_section(raw_section, index=index)
1955
+ for index, raw_section in enumerate(plan.get("sections") or [])
1956
+ ),
1957
+ metadata=builder_metadata,
1958
+ visual_review_required=_optional_bool(plan.get("visualReviewRequired")),
1959
+ preset=str(plan.get("preset") or plan.get("stylePreset") or DEFAULT_STYLE_PRESET).strip()
1960
+ or DEFAULT_STYLE_PRESET,
1961
+ )
1962
+
1963
+
1964
+ def _normalize_v2_section(raw_section: Any, *, index: int) -> BuilderSection:
1965
+ if not isinstance(raw_section, Mapping):
1966
+ raise TypeError(f"sections[{index}] must be a mapping")
1967
+ raw_blocks = raw_section.get("blocks", raw_section.get("children"))
1968
+ children: list[Any] = []
1969
+ for block_index, raw_block in enumerate(raw_blocks or []):
1970
+ normalized = _normalize_v2_block(raw_block, path=f"sections[{index}].blocks[{block_index}]")
1971
+ if isinstance(normalized, tuple):
1972
+ children.extend(normalized)
1973
+ else:
1974
+ children.append(normalized)
1975
+ return BuilderSection(
1976
+ children=tuple(children),
1977
+ page=_normalize_v2_page(raw_section.get("page")),
1978
+ margins=_normalize_v2_margins(raw_section.get("margins")),
1979
+ header=_normalize_v2_header_footer(raw_section.get("header"), kind="header"),
1980
+ footer=_normalize_v2_header_footer(raw_section.get("footer"), kind="footer"),
1981
+ )
1982
+
1983
+
1984
+ def _normalize_v2_page(value: Any) -> BuilderPageSize | None:
1985
+ if not isinstance(value, Mapping):
1986
+ return None
1987
+ preset = str(value.get("preset") or "").strip().upper()
1988
+ if preset == "A4":
1989
+ return BuilderPageSize.A4
1990
+ width = _float_value(value.get("widthMm", value.get("width_mm")), default=210)
1991
+ height = _float_value(value.get("heightMm", value.get("height_mm")), default=297)
1992
+ orientation = str(value.get("orientation") or "PORTRAIT").strip() or "PORTRAIT"
1993
+ return BuilderPageSize(width_mm=width, height_mm=height, orientation=orientation)
1994
+
1995
+
1996
+ def _normalize_v2_margins(value: Any) -> BuilderMargins | None:
1997
+ if not isinstance(value, Mapping):
1998
+ return None
1999
+ return BuilderMargins(
2000
+ top_mm=_float_value(value.get("topMm", value.get("top_mm")), default=20),
2001
+ right_mm=_float_value(value.get("rightMm", value.get("right_mm")), default=20),
2002
+ bottom_mm=_float_value(value.get("bottomMm", value.get("bottom_mm")), default=20),
2003
+ left_mm=_float_value(value.get("leftMm", value.get("left_mm")), default=20),
2004
+ header_mm=_float_value(value.get("headerMm", value.get("header_mm")), default=10),
2005
+ footer_mm=_float_value(value.get("footerMm", value.get("footer_mm")), default=10),
2006
+ gutter_mm=_float_value(value.get("gutterMm", value.get("gutter_mm")), default=0),
2007
+ )
2008
+
2009
+
2010
+ @overload
2011
+ def _normalize_v2_header_footer(
2012
+ value: Any, *, kind: Literal["header"]
2013
+ ) -> BuilderHeader | None: ...
2014
+
2015
+
2016
+ @overload
2017
+ def _normalize_v2_header_footer(
2018
+ value: Any, *, kind: Literal["footer"]
2019
+ ) -> BuilderFooter | None: ...
2020
+
2021
+
2022
+ def _normalize_v2_header_footer(
2023
+ value: Any, *, kind: Literal["header", "footer"]
2024
+ ) -> BuilderHeader | BuilderFooter | None:
2025
+ if not isinstance(value, Mapping):
2026
+ return None
2027
+ children = tuple(_normalize_v2_header_footer_child(child) for child in value.get("children") or [])
2028
+ if kind == "header":
2029
+ return BuilderHeader(children=children)
2030
+ return BuilderFooter(children=children)
2031
+
2032
+
2033
+ def _normalize_v2_header_footer_child(value: Any) -> BuilderParagraph | BuilderPageNumber:
2034
+ if not isinstance(value, Mapping):
2035
+ raise TypeError("header/footer children must be mappings")
2036
+ child_type = str(value.get("type") or "paragraph").strip()
2037
+ if child_type == "page_number":
2038
+ return BuilderPageNumber(format=str(value.get("format") or "page"))
2039
+ if child_type != "paragraph":
2040
+ raise ValueError(f"unsupported header/footer child type: {child_type!r}")
2041
+ children = tuple(_normalize_v2_paragraph_child(child) for child in value.get("children") or [])
2042
+ return BuilderParagraph(
2043
+ text=replace_computed_fields(str(value.get("text") or "")),
2044
+ children=children,
2045
+ align=_optional_str(value.get("align")),
2046
+ )
2047
+
2048
+
2049
+ def _normalize_v2_paragraph_child(value: Any) -> BuilderRun | BuilderPageNumber:
2050
+ if not isinstance(value, Mapping):
2051
+ raise TypeError("paragraph children must be mappings")
2052
+ child_type = str(value.get("type") or "run").strip()
2053
+ if child_type == "page_number":
2054
+ return BuilderPageNumber(format=str(value.get("format") or "page"))
2055
+ if child_type != "run":
2056
+ raise ValueError(f"unsupported paragraph child type: {child_type!r}")
2057
+ return BuilderRun(
2058
+ text=replace_computed_fields(str(value.get("text") or "")),
2059
+ bold=bool(value.get("bold", False)),
2060
+ italic=bool(value.get("italic", False)),
2061
+ underline=bool(value.get("underline", False)),
2062
+ color=_optional_str(value.get("color")),
2063
+ font=_optional_str(value.get("font")),
2064
+ size=_optional_number(value.get("size")),
2065
+ highlight=_optional_str(value.get("highlight")),
2066
+ strike=bool(value.get("strike", False)),
2067
+ )
2068
+
2069
+
2070
+ def _normalize_v2_block(raw_block: Any, *, path: str) -> Any:
2071
+ if not isinstance(raw_block, Mapping):
2072
+ raise TypeError(f"{path} must be a mapping")
2073
+ block_type = str(raw_block.get("type") or "").strip()
2074
+ if block_type == "heading":
2075
+ return BuilderHeading(
2076
+ level=_int_value(raw_block.get("level", 1), default=1),
2077
+ text=replace_computed_fields(str(raw_block.get("text") or "")),
2078
+ )
2079
+ if block_type == "paragraph":
2080
+ raw_children = raw_block.get("children")
2081
+ if raw_children is None:
2082
+ raw_children = raw_block.get("runs")
2083
+ children = tuple(
2084
+ child
2085
+ for child in (_normalize_v2_paragraph_child(child) for child in raw_children or [])
2086
+ if isinstance(child, BuilderRun)
2087
+ )
2088
+ return BuilderParagraph(
2089
+ text=replace_computed_fields(str(raw_block.get("text") or "")),
2090
+ children=children,
2091
+ align=_optional_str(raw_block.get("align")),
2092
+ style=_optional_str(raw_block.get("style")),
2093
+ )
2094
+ if block_type in {"bullets", "bullet"}:
2095
+ return BuilderBullet(
2096
+ items=tuple(replace_computed_fields(item) for item in _string_list(raw_block.get("items"))),
2097
+ level=_int_value(raw_block.get("level", 0), default=0),
2098
+ style=_optional_str(raw_block.get("style")),
2099
+ )
2100
+ if block_type in {"numbered_list", "numberedList"}:
2101
+ return BuilderNumberedList(
2102
+ items=tuple(replace_computed_fields(item) for item in _string_list(raw_block.get("items"))),
2103
+ level=_int_value(raw_block.get("level", 0), default=0),
2104
+ )
2105
+ if block_type == "table":
2106
+ return BuilderTable(
2107
+ header=tuple(replace_computed_fields(str(item)) for item in raw_block.get("header") or ()),
2108
+ rows=tuple(
2109
+ tuple(replace_computed_fields(str(cell)) for cell in row)
2110
+ for row in raw_block.get("rows") or ()
2111
+ ),
2112
+ merges=tuple(str(item) for item in raw_block.get("merges") or ()),
2113
+ header_shading=_optional_str(raw_block.get("headerShading", raw_block.get("header_shading"))),
2114
+ column_widths=tuple(
2115
+ _optional_number(item) or 0
2116
+ for item in raw_block.get("columnWidths", raw_block.get("column_widths")) or ()
2117
+ ),
2118
+ )
2119
+ if block_type in {"approval_box", "approvalBox"}:
2120
+ labels = tuple(replace_computed_fields(str(item)) for item in _string_list(raw_block.get("labels")))
2121
+ return BuilderApprovalBox(
2122
+ labels=labels or None,
2123
+ approver_rows=_int_value(
2124
+ raw_block.get("approverRows", raw_block.get("approver_rows")),
2125
+ default=2,
2126
+ ),
2127
+ delegated=(
2128
+ replace_computed_fields(str(raw_block.get("delegated")))
2129
+ if raw_block.get("delegated") is not None
2130
+ else None
2131
+ ),
2132
+ header_shading=str(raw_block.get("headerShading", raw_block.get("header_shading")) or "EAF1FB"),
2133
+ )
2134
+ if block_type in {"image_grid", "imageGrid"}:
2135
+ return _image_grid_builder_nodes(raw_block)
2136
+ if block_type == "image":
2137
+ return BuilderImage(
2138
+ path=str(raw_block.get("path") or ""),
2139
+ width_mm=_optional_number(raw_block.get("widthMm", raw_block.get("width_mm"))),
2140
+ align=_optional_str(raw_block.get("align")),
2141
+ caption=(
2142
+ replace_computed_fields(str(raw_block.get("caption")))
2143
+ if raw_block.get("caption") is not None
2144
+ else None
2145
+ ),
2146
+ image_format=_optional_str(raw_block.get("imageFormat", raw_block.get("image_format"))),
2147
+ )
2148
+ if block_type == "toc":
2149
+ if bool(raw_block.get("native")):
2150
+ # M9-full FR-005: lower to the Hancom-native TABLEOFCONTENTS field
2151
+ # (M7 contract) instead of the static plaintext list. Entries are
2152
+ # generated from the document's outline headings after the whole
2153
+ # plan is composed; any static "entries" are ignored (Hancom
2154
+ # regenerates the region on open with dirty=1).
2155
+ native_kwargs: dict[str, Any] = {}
2156
+ if raw_block.get("title"):
2157
+ native_kwargs["title"] = replace_computed_fields(str(raw_block["title"]))
2158
+ return BuilderNativeToc(
2159
+ level=_int_value(raw_block.get("level", 2), default=2),
2160
+ leader=_int_value(raw_block.get("leader", 3), default=3),
2161
+ hyperlink=bool(raw_block.get("hyperlink", True)),
2162
+ dirty=bool(raw_block.get("dirty", True)),
2163
+ **native_kwargs,
2164
+ )
2165
+ return BuilderToc(
2166
+ title=replace_computed_fields(str(raw_block.get("title") or "목차")),
2167
+ entries=tuple(
2168
+ {**entry, "text": replace_computed_fields(str(entry.get("text") or ""))}
2169
+ for entry in raw_block.get("entries") or ()
2170
+ if isinstance(entry, Mapping)
2171
+ ),
2172
+ )
2173
+ if block_type in {"page_break", "pageBreak"}:
2174
+ return BuilderPageBreak()
2175
+ raise ValueError(f"{path}.type is unsupported: {block_type!r}")
2176
+
2177
+
2178
+ def _image_grid_builder_nodes(raw_block: Mapping[str, Any]) -> tuple[Any, ...]:
2179
+ block = build_image_grid(
2180
+ [
2181
+ {
2182
+ "path": str(image.get("path") or ""),
2183
+ "caption": replace_computed_fields(str(image.get("caption") or "")),
2184
+ }
2185
+ for image in raw_block.get("images") or ()
2186
+ if isinstance(image, Mapping)
2187
+ ],
2188
+ columns=_int_value(raw_block.get("columns"), default=2),
2189
+ image_width_mm=_optional_number(raw_block.get("imageWidthMm", raw_block.get("image_width_mm"))),
2190
+ )
2191
+ columns = int(block["columns"])
2192
+ images = list(block["images"])
2193
+ rows: list[tuple[str, ...]] = []
2194
+ for offset in range(0, len(images), columns):
2195
+ row = []
2196
+ for image_index, image in enumerate(images[offset : offset + columns], start=offset + 1):
2197
+ row.append(f"{image_index}. {image['caption']} ({Path(str(image['path'])).name})")
2198
+ row.extend("" for _ in range(columns - len(row)))
2199
+ rows.append(tuple(row))
2200
+ image_width = _optional_number(raw_block.get("imageWidthMm", raw_block.get("image_width_mm")))
2201
+ nodes: list[Any] = [
2202
+ BuilderTable(
2203
+ header=tuple(f"사진 {index + 1}" for index in range(columns)),
2204
+ rows=tuple(rows),
2205
+ header_shading=_optional_str(raw_block.get("headerShading", raw_block.get("header_shading"))) or "EAF1FB",
2206
+ column_widths=tuple(1 for _ in range(columns)),
2207
+ )
2208
+ ]
2209
+ for image_index, image in enumerate(images, start=1):
2210
+ nodes.append(
2211
+ BuilderImage(
2212
+ path=str(image["path"]),
2213
+ width_mm=image_width,
2214
+ align=_optional_str(raw_block.get("align")) or "center",
2215
+ caption=f"{image_index}. {image['caption']}",
2216
+ )
2217
+ )
2218
+ return tuple(nodes)
2219
+
2220
+
2221
+ def _lower_plan_to_builder_document(plan: DocumentPlan) -> BuilderDocument:
2222
+ """Lower a normalized document plan to builder nodes.
2223
+
2224
+ v1 authoring keeps its historical title, metadata, style-token, and memo
2225
+ rendering contracts, so this helper lowers the body blocks into public
2226
+ builder nodes while ``create_document_from_plan`` supplies the existing
2227
+ document-level framing.
2228
+ """
2229
+
2230
+ if plan.builder_document is not None:
2231
+ return plan.builder_document
2232
+ children: list[Any] = []
2233
+ for block in plan.blocks:
2234
+ children.extend(_block_to_builder_nodes(block))
2235
+ return BuilderDocument(sections=(BuilderSection(children=tuple(children)),))
2236
+
2237
+
2238
+ def _block_to_builder_nodes(block: DocumentBlock) -> tuple[Any, ...]:
2239
+ if block.type == "heading":
2240
+ return (
2241
+ BuilderHeading(
2242
+ level=int(block.data["level"]),
2243
+ text=str(block.data["text"]),
2244
+ ),
2245
+ )
2246
+ if block.type == "paragraph":
2247
+ runs = block.data.get("runs") or []
2248
+ if runs:
2249
+ return (
2250
+ BuilderParagraph(
2251
+ text=str(block.data.get("text") or ""),
2252
+ children=tuple(_builder_run_from_plan(run) for run in runs),
2253
+ style=str(block.data.get("style") or "body"),
2254
+ ),
2255
+ )
2256
+ return (
2257
+ BuilderParagraph(
2258
+ text=str(block.data["text"]),
2259
+ style=str(block.data.get("style") or "body"),
2260
+ ),
2261
+ )
2262
+ if block.type == "bullets":
2263
+ return (BuilderBullet(items=tuple(str(item) for item in block.data["items"])),)
2264
+ if block.type == "table":
2265
+ columns = list(block.data["columns"])
2266
+ rows = list(block.data["rows"])
2267
+ nodes: list[Any] = []
2268
+ caption = str(block.data.get("caption") or "").strip()
2269
+ if caption:
2270
+ nodes.append(BuilderParagraph(text=caption, style="heading"))
2271
+ nodes.append(
2272
+ BuilderTable(
2273
+ header=tuple(str(column["label"]) for column in columns),
2274
+ rows=tuple(
2275
+ tuple(str(row.get(column["key"], "")) for column in columns)
2276
+ for row in rows
2277
+ ),
2278
+ column_widths=tuple(_plan_table_column_widths(columns)),
2279
+ ),
2280
+ )
2281
+ unit = str(block.data.get("unit") or "").strip()
2282
+ if unit:
2283
+ nodes.append(BuilderParagraph(text=unit, style="body"))
2284
+ return tuple(nodes)
2285
+ if block.type == "memo":
2286
+ return (block,)
2287
+ if block.type == "page_break":
2288
+ return (BuilderPageBreak(),)
2289
+ raise ValueError(f"unsupported block type: {block.type!r}")
2290
+
2291
+
2292
+ def _builder_run_from_plan(run: Mapping[str, Any]) -> BuilderRun:
2293
+ return BuilderRun(
2294
+ text=str(run.get("text") or ""),
2295
+ bold=bool(run.get("bold", False)),
2296
+ color=_optional_str(run.get("color")),
2297
+ )
2298
+
2299
+
2300
+ def _plan_table_column_widths(columns: list[dict[str, Any]]) -> list[int]:
2301
+ total = sum(max(int(column.get("widthWeight", 1)), 1) for column in columns)
2302
+ if total <= 0:
2303
+ return []
2304
+ widths = [
2305
+ round(_DEFAULT_TABLE_WIDTH * max(int(column.get("widthWeight", 1)), 1) / total)
2306
+ for column in columns
2307
+ ]
2308
+ if widths:
2309
+ widths[-1] += _DEFAULT_TABLE_WIDTH - sum(widths)
2310
+ return widths
2311
+
2312
+
2313
+ def _normalize_columns(value: Any, *, index: int) -> list[dict[str, Any]]:
2314
+ if not isinstance(value, list) or not value:
2315
+ raise ValueError(f"blocks[{index}].columns must be a non-empty list")
2316
+ columns: list[dict[str, Any]] = []
2317
+ seen: set[str] = set()
2318
+ for col_index, raw_column in enumerate(value):
2319
+ if not isinstance(raw_column, Mapping):
2320
+ raise ValueError(f"blocks[{index}].columns[{col_index}] must be a mapping")
2321
+ key = str(raw_column.get("key") or "").strip()
2322
+ if not key:
2323
+ raise ValueError(f"blocks[{index}].columns[{col_index}].key is required")
2324
+ if key in seen:
2325
+ raise ValueError(f"blocks[{index}].columns contains duplicate key: {key!r}")
2326
+ seen.add(key)
2327
+ label = normalize_cell_text(raw_column.get("label") or key)
2328
+ width_weight = _int_value(raw_column.get("widthWeight", 1), default=1)
2329
+ columns.append(
2330
+ {
2331
+ "key": key,
2332
+ "label": label,
2333
+ "widthWeight": max(width_weight, 1),
2334
+ }
2335
+ )
2336
+ return columns
2337
+
2338
+
2339
+ def _normalize_rows(
2340
+ value: Any,
2341
+ columns: list[dict[str, Any]],
2342
+ *,
2343
+ index: int,
2344
+ ) -> list[dict[str, str]]:
2345
+ if not isinstance(value, list) or not value:
2346
+ raise ValueError(f"blocks[{index}].rows must be a non-empty list")
2347
+ column_keys = [column["key"] for column in columns]
2348
+ rows: list[dict[str, str]] = []
2349
+ for row_index, raw_row in enumerate(value):
2350
+ if not isinstance(raw_row, Mapping):
2351
+ raise ValueError(f"blocks[{index}].rows[{row_index}] must be a mapping")
2352
+ rows.append({key: _normalize_table_cell_value(raw_row.get(key, "")) for key in column_keys})
2353
+ return rows
2354
+
2355
+
2356
+ def _normalize_table_cell_value(value: Any) -> str:
2357
+ if isinstance(value, Mapping):
2358
+ text = value.get("text", value.get("value", ""))
2359
+ if bool(value.get("preserveWhitespace", False)):
2360
+ return str(text or "")
2361
+ return normalize_cell_text(text)
2362
+ return normalize_cell_text(value)
2363
+
2364
+
2365
+ def _format_para(
2366
+ document: HwpxDocument,
2367
+ paragraph: Any,
2368
+ *,
2369
+ alignment: str | None = None,
2370
+ line_spacing: int | None = None,
2371
+ before_pt: float | None = None,
2372
+ after_pt: float | None = None,
2373
+ bottom_border: bool = False,
2374
+ border_color: str = "#BFBFBF",
2375
+ ) -> None:
2376
+ """Apply breathing-room paragraph spacing to a freshly added paragraph.
2377
+
2378
+ Uses the public ``set_paragraph_format`` so unit conversion and paraPr
2379
+ deduplication are handled by the engine. Failures are non-fatal: spacing is
2380
+ a presentation nicety, never a reason to abort document generation.
2381
+ """
2382
+
2383
+ kwargs: dict[str, Any] = {}
2384
+ if alignment is not None:
2385
+ kwargs["alignment"] = alignment
2386
+ if line_spacing is not None:
2387
+ kwargs["line_spacing_percent"] = line_spacing
2388
+ if before_pt is not None:
2389
+ kwargs["spacing_before_pt"] = before_pt
2390
+ if after_pt is not None:
2391
+ kwargs["spacing_after_pt"] = after_pt
2392
+ if bottom_border:
2393
+ kwargs["bottom_border"] = True
2394
+ kwargs["border_color"] = border_color
2395
+ if not kwargs:
2396
+ return
2397
+ try:
2398
+ index = document.paragraphs.index(paragraph)
2399
+ document.set_paragraph_format(paragraph_index=index, **kwargs)
2400
+ except (ValueError, KeyError):
2401
+ return
2402
+
2403
+
2404
+ def _add_rich_runs(
2405
+ document: HwpxDocument,
2406
+ paragraph: Any,
2407
+ runs: Any,
2408
+ *,
2409
+ base_char_pr_id: str,
2410
+ ) -> None:
2411
+ for run in runs:
2412
+ if not isinstance(run, BuilderRun):
2413
+ raise ValueError(f"unsupported paragraph child: {type(run).__name__}")
2414
+ char_pr_id = document.ensure_run_style(
2415
+ bold=bool(run.bold),
2416
+ italic=bool(run.italic),
2417
+ underline=bool(run.underline),
2418
+ color=run.color,
2419
+ font=run.font,
2420
+ size=run.size,
2421
+ highlight=run.highlight,
2422
+ strike=run.strike,
2423
+ base_char_pr_id=base_char_pr_id,
2424
+ )
2425
+ paragraph.add_run(str(run.text or ""), char_pr_id_ref=char_pr_id)
2426
+
2427
+
2428
+ def _render_block(
2429
+ document: HwpxDocument,
2430
+ block: Any,
2431
+ tokens: Mapping[str, str],
2432
+ *,
2433
+ style_preset: DocumentStylePreset,
2434
+ ) -> None:
2435
+ if isinstance(block, BuilderHeading):
2436
+ paragraph = document.add_paragraph(
2437
+ block.text,
2438
+ char_pr_id_ref=tokens["heading"],
2439
+ inherit_style=False,
2440
+ **cast(Any, _outline_style_refs(document, block.level)),
2441
+ )
2442
+ _format_para(
2443
+ document,
2444
+ paragraph,
2445
+ line_spacing=150,
2446
+ before_pt=14,
2447
+ after_pt=4,
2448
+ bottom_border=style_preset.heading_rule,
2449
+ border_color=style_preset.rule_color,
2450
+ )
2451
+ return
2452
+ if isinstance(block, BuilderParagraph):
2453
+ style = str(block.style or "body")
2454
+ if block.children:
2455
+ paragraph = document.add_paragraph("", include_run=False, inherit_style=False)
2456
+ _add_rich_runs(
2457
+ document,
2458
+ paragraph,
2459
+ block.children,
2460
+ base_char_pr_id=tokens.get(style, tokens["body"]),
2461
+ )
2462
+ _format_para(
2463
+ document,
2464
+ paragraph,
2465
+ line_spacing=165,
2466
+ after_pt=4,
2467
+ bottom_border=style == "heading" and style_preset.heading_rule,
2468
+ border_color=style_preset.rule_color,
2469
+ )
2470
+ return
2471
+ paragraph = document.add_paragraph(
2472
+ block.text,
2473
+ char_pr_id_ref=tokens.get(style, tokens["body"]),
2474
+ inherit_style=False,
2475
+ )
2476
+ _format_para(
2477
+ document,
2478
+ paragraph,
2479
+ line_spacing=165,
2480
+ after_pt=4,
2481
+ bottom_border=style == "heading" and style_preset.heading_rule,
2482
+ border_color=style_preset.rule_color,
2483
+ )
2484
+ return
2485
+ if isinstance(block, BuilderBullet):
2486
+ for item in block.items:
2487
+ paragraph = document.add_paragraph(
2488
+ f"• {item}",
2489
+ char_pr_id_ref=tokens["bullet"],
2490
+ inherit_style=False,
2491
+ )
2492
+ _format_para(document, paragraph, line_spacing=150, after_pt=2)
2493
+ return
2494
+ if isinstance(block, BuilderTable):
2495
+ _add_builder_table(document, block, tokens)
2496
+ return
2497
+ if isinstance(block, DocumentBlock) and block.type == "memo":
2498
+ paragraph = document.add_paragraph(
2499
+ str(block.data["text"]),
2500
+ char_pr_id_ref=tokens["body"],
2501
+ inherit_style=False,
2502
+ )
2503
+ document.add_memo_with_anchor(str(block.data["memo"]), paragraph=paragraph)
2504
+ return
2505
+ if isinstance(block, BuilderPageBreak):
2506
+ document.add_paragraph("", pageBreak="1", inherit_style=False)
2507
+ return
2508
+ raise ValueError(f"unsupported builder block: {type(block).__name__}")
2509
+
2510
+
2511
+ def _add_key_value_table(
2512
+ document: HwpxDocument,
2513
+ metadata: Mapping[str, str],
2514
+ tokens: Mapping[str, str],
2515
+ ) -> None:
2516
+ rows = [
2517
+ {"key": _metadata_label(key), "value": value}
2518
+ for key, value in metadata.items()
2519
+ ]
2520
+ block = {
2521
+ "caption": "",
2522
+ "columns": [
2523
+ {"key": "key", "label": "항목", "widthWeight": 1},
2524
+ {"key": "value", "label": "내용", "widthWeight": 2},
2525
+ ],
2526
+ "rows": rows,
2527
+ }
2528
+ _add_plan_table(document, block, tokens)
2529
+
2530
+
2531
+ def _metadata_label(key: str) -> str:
2532
+ return _METADATA_LABELS.get(key, key)
2533
+
2534
+
2535
+ def _add_plan_table(
2536
+ document: HwpxDocument,
2537
+ block: Mapping[str, Any],
2538
+ tokens: Mapping[str, str],
2539
+ ) -> None:
2540
+ caption = str(block.get("caption") or "").strip()
2541
+ if caption:
2542
+ document.add_paragraph(
2543
+ caption,
2544
+ char_pr_id_ref=tokens["heading"],
2545
+ inherit_style=False,
2546
+ )
2547
+ columns = list(block["columns"])
2548
+ rows = list(block["rows"])
2549
+ table = document.add_table(
2550
+ len(rows) + 1,
2551
+ len(columns),
2552
+ width=_DEFAULT_TABLE_WIDTH,
2553
+ char_pr_id_ref=tokens["table_cell"],
2554
+ )
2555
+ _apply_column_widths(table, columns)
2556
+ for col_index, column in enumerate(columns):
2557
+ _set_table_cell_text(
2558
+ table,
2559
+ 0,
2560
+ col_index,
2561
+ str(column["label"]),
2562
+ char_pr_id_ref=tokens["table_header"],
2563
+ )
2564
+ for row_index, row in enumerate(rows, start=1):
2565
+ for col_index, column in enumerate(columns):
2566
+ _set_table_cell_text(
2567
+ table,
2568
+ row_index,
2569
+ col_index,
2570
+ str(row.get(column["key"], "")),
2571
+ char_pr_id_ref=tokens["table_cell"],
2572
+ )
2573
+ _style_plan_table(document, table, header_fill=_TABLE_HEADER_FILL)
2574
+
2575
+
2576
+ def _add_builder_table(
2577
+ document: HwpxDocument,
2578
+ table_node: BuilderTable,
2579
+ tokens: Mapping[str, str],
2580
+ ) -> None:
2581
+ rows = [list(table_node.header), *(list(row) for row in table_node.rows)]
2582
+ if not rows:
2583
+ raise ValueError("table must contain a header or at least one row")
2584
+ column_count = max(len(row) for row in rows)
2585
+ table = document.add_table(
2586
+ len(rows),
2587
+ column_count,
2588
+ width=_DEFAULT_TABLE_WIDTH,
2589
+ char_pr_id_ref=tokens["table_cell"],
2590
+ )
2591
+ if table_node.column_widths:
2592
+ for row in table.rows:
2593
+ for col_index, cell in enumerate(row.cells):
2594
+ if col_index < len(table_node.column_widths):
2595
+ cell.set_size(width=int(table_node.column_widths[col_index]))
2596
+ for col_index, label in enumerate(table_node.header):
2597
+ _set_table_cell_text(
2598
+ table,
2599
+ 0,
2600
+ col_index,
2601
+ str(label),
2602
+ char_pr_id_ref=tokens["table_header"],
2603
+ )
2604
+ row_offset = 1 if table_node.header else 0
2605
+ for row_index, row in enumerate(table_node.rows, start=row_offset):
2606
+ for col_index, value in enumerate(row):
2607
+ _set_table_cell_text(
2608
+ table,
2609
+ row_index,
2610
+ col_index,
2611
+ str(value),
2612
+ char_pr_id_ref=tokens["table_cell"],
2613
+ )
2614
+ _style_plan_table(
2615
+ document,
2616
+ table,
2617
+ header_fill=table_node.header_shading or _TABLE_HEADER_FILL,
2618
+ header_rows=1 if table_node.header else 0,
2619
+ )
2620
+
2621
+
2622
+ def _set_table_cell_text(
2623
+ table: Any,
2624
+ row_index: int,
2625
+ col_index: int,
2626
+ text: str,
2627
+ *,
2628
+ char_pr_id_ref: str,
2629
+ ) -> None:
2630
+ table.set_cell_text(row_index, col_index, text)
2631
+ cell = table.cell(row_index, col_index)
2632
+ for paragraph in cell.paragraphs:
2633
+ paragraph.char_pr_id_ref = char_pr_id_ref
2634
+
2635
+
2636
+ def _style_plan_table(
2637
+ document: HwpxDocument,
2638
+ table: Any,
2639
+ *,
2640
+ header_fill: str,
2641
+ header_rows: int = 1,
2642
+ ) -> None:
2643
+ border_fill_id = document.ensure_border_fill(border_color=_TABLE_BORDER_COLOR)
2644
+ header_fill_id = document.ensure_border_fill(
2645
+ border_color=_TABLE_BORDER_COLOR,
2646
+ fill_color=header_fill,
2647
+ )
2648
+ table.element.set("borderFillIDRef", border_fill_id)
2649
+ center_para_pr_id: str | None = None
2650
+ if header_rows and document.oxml.headers:
2651
+ center_para_pr_id = document.oxml.headers[0].ensure_paragraph_format(alignment="center")
2652
+
2653
+ for row_index, row in enumerate(table.rows):
2654
+ for cell in row.cells:
2655
+ is_header = row_index < header_rows
2656
+ cell.element.set("borderFillIDRef", header_fill_id if is_header else border_fill_id)
2657
+ cell.element.set("hasMargin", "1")
2658
+ _set_cell_margin(cell)
2659
+ sublist = cell.element.find(f"{_HP}subList")
2660
+ if sublist is not None:
2661
+ sublist.set("vertAlign", "CENTER")
2662
+ if is_header and center_para_pr_id is not None:
2663
+ for paragraph in cell.paragraphs:
2664
+ paragraph.para_pr_id_ref = center_para_pr_id
2665
+ table.mark_dirty()
2666
+
2667
+
2668
+ def _set_cell_margin(cell: Any) -> None:
2669
+ margin = cell.element.find(f"{_HP}cellMargin")
2670
+ if margin is None:
2671
+ margin = cell.element.makeelement(f"{_HP}cellMargin", {})
2672
+ cell.element.append(margin)
2673
+ for side in ("left", "right", "top", "bottom"):
2674
+ margin.set(side, _TABLE_CELL_MARGIN)
2675
+
2676
+
2677
+ def _apply_column_widths(table: Any, columns: list[dict[str, Any]]) -> None:
2678
+ total = sum(max(int(column.get("widthWeight", 1)), 1) for column in columns)
2679
+ if total <= 0:
2680
+ return
2681
+ widths = [
2682
+ round(_DEFAULT_TABLE_WIDTH * max(int(column.get("widthWeight", 1)), 1) / total)
2683
+ for column in columns
2684
+ ]
2685
+ if widths:
2686
+ widths[-1] += _DEFAULT_TABLE_WIDTH - sum(widths)
2687
+ for row in table.rows:
2688
+ for col_index, cell in enumerate(row.cells):
2689
+ if col_index < len(widths):
2690
+ cell.set_size(width=widths[col_index])
2691
+
2692
+
2693
+ def _default_quality_gates() -> dict[str, Any]:
2694
+ return {
2695
+ "validatePackage": True,
2696
+ "validateDocument": True,
2697
+ "reopen": True,
2698
+ "minNonEmptyParagraphs": 1,
2699
+ "minTableCount": 0,
2700
+ "requiredText": [],
2701
+ "visualReviewRequired": True,
2702
+ }
2703
+
2704
+
2705
+ def _evaluate_quality_gates(
2706
+ gates: Mapping[str, Any],
2707
+ *,
2708
+ package_ok: bool,
2709
+ document_ok: bool,
2710
+ reopened: bool,
2711
+ non_empty_paragraph_count: int,
2712
+ table_count: int,
2713
+ full_text: str,
2714
+ ) -> dict[str, bool]:
2715
+ min_paragraphs = _int_value(gates.get("minNonEmptyParagraphs", 1), default=1)
2716
+ min_tables = _int_value(gates.get("minTableCount", 0), default=0)
2717
+ required_text = _string_list(gates.get("requiredText") or [])
2718
+ return {
2719
+ "validatePackage": (not gates.get("validatePackage", True)) or package_ok,
2720
+ "validateDocument": (not gates.get("validateDocument", True)) or document_ok,
2721
+ "reopen": (not gates.get("reopen", True)) or reopened,
2722
+ "minNonEmptyParagraphs": non_empty_paragraph_count >= min_paragraphs,
2723
+ "minTableCount": table_count >= min_tables,
2724
+ "requiredText": all(text in full_text for text in required_text),
2725
+ "visualReviewRequired": bool(gates.get("visualReviewRequired", True)),
2726
+ }
2727
+
2728
+
2729
+ def _block_counts(plan: DocumentPlan | None) -> dict[str, int]:
2730
+ counts: dict[str, int] = {}
2731
+ if plan is None:
2732
+ return counts
2733
+ for block in plan.blocks:
2734
+ counts[block.type] = counts.get(block.type, 0) + 1
2735
+ return counts
2736
+
2737
+
2738
+ def _style_usage(document: HwpxDocument) -> dict[str, Any]:
2739
+ used_ids: set[str] = set()
2740
+ for run in _iter_runs_deep(document):
2741
+ value = getattr(run, "char_pr_id_ref", None)
2742
+ if value is not None:
2743
+ used_ids.add(str(value))
2744
+ return {
2745
+ "used_run_style_ids": sorted(used_ids),
2746
+ "used_run_style_count": len(used_ids),
2747
+ "available_char_style_count": len(document.char_properties),
2748
+ }
2749
+
2750
+
2751
+ def _content_evidence(gates: Mapping[str, Any], *, full_text: str) -> dict[str, Any]:
2752
+ required_text = _string_list(gates.get("requiredText") or [])
2753
+ return {
2754
+ "text_char_count": len(full_text),
2755
+ "required_text": [
2756
+ {"text": text, "present": text in full_text}
2757
+ for text in required_text
2758
+ ],
2759
+ }
2760
+
2761
+
2762
+ def _profile_reports(
2763
+ document: HwpxDocument,
2764
+ *,
2765
+ normalized_plan: DocumentPlan | None,
2766
+ quality_profile: str | Mapping[str, Any] | None,
2767
+ ) -> dict[str, Any]:
2768
+ profile_name = _requested_profile_name(
2769
+ quality_profile,
2770
+ normalized_plan=normalized_plan,
2771
+ )
2772
+ if profile_name != "operating_plan":
2773
+ return {}
2774
+
2775
+ profile_options = quality_profile if isinstance(quality_profile, Mapping) else None
2776
+ return {
2777
+ "operating_plan": _inspect_operating_plan_quality(
2778
+ document,
2779
+ normalized_plan=normalized_plan,
2780
+ profile=profile_options,
2781
+ )
2782
+ }
2783
+
2784
+
2785
+ def _requested_profile_name(
2786
+ quality_profile: str | Mapping[str, Any] | None,
2787
+ *,
2788
+ normalized_plan: DocumentPlan | None,
2789
+ ) -> str | None:
2790
+ if isinstance(quality_profile, str):
2791
+ value = quality_profile.strip().lower().replace("-", "_")
2792
+ return value or None
2793
+ if isinstance(quality_profile, Mapping):
2794
+ value = str(
2795
+ quality_profile.get("name")
2796
+ or quality_profile.get("profile")
2797
+ or quality_profile.get("profileName")
2798
+ or "operating_plan"
2799
+ )
2800
+ return value.strip().lower().replace("-", "_") or None
2801
+ if normalized_plan is None:
2802
+ return None
2803
+
2804
+ document_type = normalized_plan.metadata.get("document_type", "")
2805
+ title = normalized_plan.title
2806
+ haystack = f"{document_type} {title}".lower()
2807
+ if "operating_plan" in haystack or "운영계획서" in haystack:
2808
+ return "operating_plan"
2809
+ return None
2810
+
2811
+
2812
+ def _inspect_operating_plan_quality(
2813
+ document: HwpxDocument,
2814
+ *,
2815
+ normalized_plan: DocumentPlan | None,
2816
+ profile: Mapping[str, Any] | None,
2817
+ ) -> dict[str, Any]:
2818
+ options = _operating_plan_profile(profile)
2819
+ full_text = document.export_text()
2820
+ table_text = _table_text(document)
2821
+ plan_text = _plan_text(normalized_plan)
2822
+ all_text = "\n".join(text for text in (full_text, table_text, plan_text) if text)
2823
+ file_lines = _document_text_lines(document)
2824
+ table_blocks = (
2825
+ _plan_table_blocks(normalized_plan)
2826
+ if normalized_plan is not None
2827
+ else _document_table_blocks(document)
2828
+ )
2829
+ non_empty_paragraphs = [
2830
+ (paragraph.text or "").strip()
2831
+ for paragraph in document.paragraphs
2832
+ if (paragraph.text or "").strip()
2833
+ ]
2834
+ amount_count = len(re.findall(r"\d[\d,]*(?:\.\d+)?\s*(?:원|만원|천원|%)", all_text))
2835
+
2836
+ section_results = {
2837
+ name: _contains_any(all_text, values)
2838
+ for name, values in options["required_sections"].items()
2839
+ }
2840
+ front_matter = (
2841
+ _front_matter_dimension(normalized_plan, options)
2842
+ if normalized_plan is not None
2843
+ else _front_matter_from_file_text(file_lines, options)
2844
+ )
2845
+ dimensions = {
2846
+ "front_matter": front_matter,
2847
+ "required_outline": _dimension(
2848
+ present=all(section_results.values()),
2849
+ score=5.0 if all(section_results.values()) else max(
2850
+ 2.0,
2851
+ 5.0 * sum(section_results.values()) / len(section_results),
2852
+ ),
2853
+ metrics={"required_sections": section_results},
2854
+ fail_reason="missing required operating-plan sections",
2855
+ repair_hint=(
2856
+ "Add the missing operating-plan headings and body content: "
2857
+ + ", ".join(name for name, present in section_results.items() if not present)
2858
+ + "."
2859
+ ),
2860
+ ),
2861
+ "content_density": _dimension(
2862
+ present=(
2863
+ len(all_text) >= int(options["min_text_chars"])
2864
+ and len(non_empty_paragraphs) >= int(options["min_non_empty_paragraphs"])
2865
+ ),
2866
+ score=5.0
2867
+ if len(all_text) >= int(options["min_text_chars"])
2868
+ and len(non_empty_paragraphs) >= int(options["min_non_empty_paragraphs"])
2869
+ else 3.0,
2870
+ metrics={
2871
+ "text_char_count": len(all_text),
2872
+ "non_empty_paragraph_count": len(non_empty_paragraphs),
2873
+ "min_text_chars": int(options["min_text_chars"]),
2874
+ "min_non_empty_paragraphs": int(options["min_non_empty_paragraphs"]),
2875
+ },
2876
+ fail_reason="operating-plan content is too sparse",
2877
+ repair_hint="Expand section body text with school context, implementation detail, evidence, and review criteria.",
2878
+ ),
2879
+ "schedule_table": _schedule_table_dimension(all_text, table_blocks, options),
2880
+ "budget_resource_evidence": _budget_dimension(
2881
+ all_text,
2882
+ table_blocks,
2883
+ amount_count=amount_count,
2884
+ options=options,
2885
+ ),
2886
+ "expected_outcomes": _dimension(
2887
+ present=_contains_any(all_text, options["expected_outcome_terms"]),
2888
+ score=5.0 if _contains_any(all_text, options["expected_outcome_terms"]) else 2.0,
2889
+ metrics={"terms": options["expected_outcome_terms"]},
2890
+ fail_reason="missing expected outcomes or performance-management evidence",
2891
+ repair_hint="Add a 기대 효과/성과 관리 section with measurable outcomes and review evidence.",
2892
+ ),
2893
+ "closing_material": _dimension(
2894
+ present=_contains_any(all_text, options["closing_terms"]),
2895
+ score=5.0 if _contains_any(all_text, options["closing_terms"]) else 2.0,
2896
+ metrics={"terms": options["closing_terms"]},
2897
+ fail_reason="missing closing, submission, review, or confirmation material",
2898
+ repair_hint="Add a closing/submission block that states review, confirmation, submission, or signature context.",
2899
+ ),
2900
+ "placeholder_residue": _placeholder_dimension(all_text, options),
2901
+ }
2902
+ average = round(
2903
+ sum(float(dimension["score"]) for dimension in dimensions.values()) / len(dimensions),
2904
+ 2,
2905
+ )
2906
+ gaps = [
2907
+ f"{name}: {dimension['reason']}"
2908
+ for name, dimension in dimensions.items()
2909
+ if dimension["status"] == "fail"
2910
+ ]
2911
+ repair_hints = [
2912
+ {
2913
+ "dimension": name,
2914
+ "action": "revise",
2915
+ "message": str(dimension["repair_hint"]),
2916
+ }
2917
+ for name, dimension in dimensions.items()
2918
+ if dimension["status"] == "fail" and dimension.get("repair_hint")
2919
+ ]
2920
+ passed = average >= 4.0 and not gaps
2921
+ status = "ready" if passed else "needs_revision"
2922
+ return {
2923
+ "report_version": OPERATING_PLAN_QUALITY_VERSION,
2924
+ "profile_version": OPERATING_PLAN_QUALITY_VERSION,
2925
+ "profile_name": "operating_plan",
2926
+ "status": status,
2927
+ "pass": passed,
2928
+ "score": average,
2929
+ "dimensions": dimensions,
2930
+ "gaps": gaps,
2931
+ "repair_hints": repair_hints,
2932
+ "visual_review_required": True,
2933
+ "limitations": [
2934
+ "This profile checks deterministic text/table/package proxies only.",
2935
+ "Submission-quality form fit still requires rendered or human visual review.",
2936
+ ],
2937
+ }
2938
+
2939
+
2940
+ def _document_text_lines(document: HwpxDocument) -> list[str]:
2941
+ full_text = document.export_text()
2942
+ table_text = _table_text(document)
2943
+ lines: list[str] = []
2944
+ for chunk in (full_text, table_text):
2945
+ for line in str(chunk or "").splitlines():
2946
+ stripped = line.strip()
2947
+ if stripped:
2948
+ lines.append(stripped)
2949
+ return lines
2950
+
2951
+
2952
+ def _front_matter_from_file_text(
2953
+ lines: list[str],
2954
+ options: Mapping[str, Any],
2955
+ ) -> dict[str, Any]:
2956
+ evidence_lines = lines[:20]
2957
+ required_labels = {
2958
+ "organization": ["기관", "신청 기관", "신청기관", "소속 기관", "소속기관", "학교명", "학 교 명"],
2959
+ "date": ["작성일", "작성 일", "일자", "날짜"],
2960
+ "document_type": ["문서 유형", "문서유형", "문서 종류", "문서종류"],
2961
+ }
2962
+ required_metadata = set(_string_list(options.get("required_metadata") or []))
2963
+ metrics = {
2964
+ key: _has_front_matter_label_value(evidence_lines, labels)
2965
+ for key, labels in required_labels.items()
2966
+ if key in required_metadata
2967
+ }
2968
+ present = bool(metrics) and all(metrics.values())
2969
+ return _dimension(
2970
+ present=present,
2971
+ score=5.0 if present else 2.5,
2972
+ metrics={"required_metadata": metrics},
2973
+ fail_reason="missing front matter metadata evidence",
2974
+ repair_hint="Add visible document metadata near the beginning: 기관, 작성일, and 문서 유형.",
2975
+ )
2976
+
2977
+
2978
+ def _has_front_matter_label_value(lines: list[str], labels: list[str]) -> bool:
2979
+ return any(_line_starts_with_label_value(line, labels) for line in lines)
2980
+
2981
+
2982
+ def _line_starts_with_label_value(line: str, labels: list[str]) -> bool:
2983
+ for label in labels:
2984
+ if re.search(rf"^\s*{re.escape(label)}\s*[::\t -]+\S", line):
2985
+ return True
2986
+ return False
2987
+
2988
+
2989
+ def _operating_plan_profile(profile: Mapping[str, Any] | None) -> dict[str, Any]:
2990
+ options: dict[str, Any] = {
2991
+ "required_metadata": ["organization", "date", "document_type"],
2992
+ "required_sections": {
2993
+ "purpose": ["신청 목적", "운영 목적", "목적"],
2994
+ "operating_plan": ["운영 계획", "운영과제", "추진 전략"],
2995
+ "schedule_budget": ["추진 일정", "사업비 사용 계획", "예산"],
2996
+ "curriculum": ["교육과정", "편제표", "교과"],
2997
+ "expected_outcomes": ["기대 효과", "성과 관리", "성과"],
2998
+ "closing": ["제출", "확인", "검토", "서명", "마무리"],
2999
+ },
3000
+ "schedule_terms": ["추진 일정", "기간", "단계", "월"],
3001
+ "budget_terms": ["사업비", "예산", "금액", "산출근거", "자원"],
3002
+ "expected_outcome_terms": ["기대 효과", "성과 관리", "성과"],
3003
+ "closing_terms": ["제출", "확인", "검토", "서명", "마무리"],
3004
+ "placeholder_patterns": [
3005
+ r"TODO",
3006
+ r"TBD",
3007
+ r"작성\s*필요",
3008
+ r"입력하세요",
3009
+ r"□□□□",
3010
+ r"○○",
3011
+ r"\.\.\.",
3012
+ r"\(학교상황",
3013
+ r"\(운영\s*목표\)",
3014
+ ],
3015
+ "min_text_chars": 900,
3016
+ "min_non_empty_paragraphs": 12,
3017
+ "min_schedule_rows": 3,
3018
+ "min_budget_rows": 2,
3019
+ "min_amount_mentions": 2,
3020
+ }
3021
+ if profile:
3022
+ for key, value in profile.items():
3023
+ if key in {"name", "profile", "profileName"}:
3024
+ continue
3025
+ normalized_key = _camel_to_snake(str(key))
3026
+ if normalized_key in options:
3027
+ options[normalized_key] = value
3028
+ elif key in options:
3029
+ options[str(key)] = value
3030
+ return options
3031
+
3032
+
3033
+ def _front_matter_dimension(
3034
+ normalized_plan: DocumentPlan | None,
3035
+ options: Mapping[str, Any],
3036
+ ) -> dict[str, Any]:
3037
+ metadata = normalized_plan.metadata if normalized_plan is not None else {}
3038
+ required = _string_list(options.get("required_metadata") or [])
3039
+ present = {key: bool(str(metadata.get(key, "")).strip()) for key in required}
3040
+ passed = all(present.values()) if present else True
3041
+ return _dimension(
3042
+ present=passed,
3043
+ score=5.0 if passed else max(2.0, 5.0 * sum(present.values()) / max(1, len(present))),
3044
+ metrics={"required_metadata": present},
3045
+ fail_reason="missing required front-matter metadata",
3046
+ repair_hint="Fill operating-plan metadata such as organization, date, and document_type before generation.",
3047
+ )
3048
+
3049
+
3050
+ def _schedule_table_dimension(
3051
+ all_text: str,
3052
+ table_blocks: list[Mapping[str, Any]],
3053
+ options: Mapping[str, Any],
3054
+ ) -> dict[str, Any]:
3055
+ min_rows = int(options["min_schedule_rows"])
3056
+ matching_table = _find_plan_table(table_blocks, options["schedule_terms"])
3057
+ row_count = len(matching_table.get("rows", [])) if matching_table is not None else 0
3058
+ fallback_present = _contains_any(all_text, options["schedule_terms"])
3059
+ passed = matching_table is not None and row_count >= min_rows
3060
+ return _dimension(
3061
+ present=passed,
3062
+ score=5.0 if passed else (3.0 if fallback_present else 2.0),
3063
+ metrics={
3064
+ "has_schedule_terms": fallback_present,
3065
+ "table_row_count": row_count,
3066
+ "plan_table_row_count": row_count,
3067
+ "min_schedule_rows": min_rows,
3068
+ },
3069
+ fail_reason="missing schedule table with enough execution rows",
3070
+ repair_hint="Add a 추진 일정 table with preparation, operation, and evaluation rows including period/activity/owner.",
3071
+ )
3072
+
3073
+
3074
+ def _budget_dimension(
3075
+ all_text: str,
3076
+ table_blocks: list[Mapping[str, Any]],
3077
+ *,
3078
+ amount_count: int,
3079
+ options: Mapping[str, Any],
3080
+ ) -> dict[str, Any]:
3081
+ min_rows = int(options["min_budget_rows"])
3082
+ min_amounts = int(options["min_amount_mentions"])
3083
+ matching_table = _find_plan_table(table_blocks, options["budget_terms"])
3084
+ row_count = len(matching_table.get("rows", [])) if matching_table is not None else 0
3085
+ has_terms = _contains_any(all_text, options["budget_terms"])
3086
+ passed = matching_table is not None and row_count >= min_rows and amount_count >= min_amounts
3087
+ return _dimension(
3088
+ present=passed,
3089
+ score=5.0 if passed else (3.0 if matching_table is not None or has_terms else 2.0),
3090
+ metrics={
3091
+ "has_budget_terms": has_terms,
3092
+ "table_row_count": row_count,
3093
+ "plan_table_row_count": row_count,
3094
+ "amount_mentions": amount_count,
3095
+ "min_budget_rows": min_rows,
3096
+ "min_amount_mentions": min_amounts,
3097
+ },
3098
+ fail_reason="missing budget/resource evidence with amounts and calculation basis",
3099
+ repair_hint="Add a 사업비/예산 table with item, amount, ratio or calculation-basis rows.",
3100
+ )
3101
+
3102
+
3103
+ def _placeholder_dimension(
3104
+ all_text: str,
3105
+ options: Mapping[str, Any],
3106
+ ) -> dict[str, Any]:
3107
+ patterns = _string_list(options.get("placeholder_patterns") or [])
3108
+ hits = [
3109
+ pattern
3110
+ for pattern in patterns
3111
+ if re.search(pattern, all_text, flags=re.IGNORECASE)
3112
+ ]
3113
+ return _dimension(
3114
+ present=not hits,
3115
+ score=5.0 if not hits else 1.5,
3116
+ metrics={"matched_patterns": hits},
3117
+ fail_reason="template placeholders or drafting markers remain",
3118
+ repair_hint="Replace placeholder text with final school-specific content before handoff.",
3119
+ )
3120
+
3121
+
3122
+ def _dimension(
3123
+ *,
3124
+ present: bool,
3125
+ score: float,
3126
+ metrics: Mapping[str, Any],
3127
+ fail_reason: str,
3128
+ repair_hint: str,
3129
+ ) -> dict[str, Any]:
3130
+ return {
3131
+ "status": "pass" if present else "fail",
3132
+ "score": round(score, 2),
3133
+ "metrics": dict(metrics),
3134
+ "reason": "" if present else fail_reason,
3135
+ "repair_hint": "" if present else repair_hint,
3136
+ }
3137
+
3138
+
3139
+ def _plan_table_blocks(normalized_plan: DocumentPlan | None) -> list[Mapping[str, Any]]:
3140
+ if normalized_plan is None:
3141
+ return []
3142
+ return [
3143
+ block.data
3144
+ for block in normalized_plan.blocks
3145
+ if block.type == "table"
3146
+ ]
3147
+
3148
+
3149
+ def _find_plan_table(
3150
+ table_blocks: list[Mapping[str, Any]],
3151
+ terms: Any,
3152
+ ) -> Mapping[str, Any] | None:
3153
+ for block in table_blocks:
3154
+ blob = _table_block_text(block)
3155
+ if _contains_any(blob, terms):
3156
+ return block
3157
+ return None
3158
+
3159
+
3160
+ def _table_block_text(block: Mapping[str, Any]) -> str:
3161
+ parts = [str(block.get("caption") or "")]
3162
+ for column in block.get("columns", []):
3163
+ if isinstance(column, Mapping):
3164
+ parts.extend([str(column.get("key") or ""), str(column.get("label") or "")])
3165
+ for row in block.get("rows", []):
3166
+ if isinstance(row, Mapping):
3167
+ parts.extend(str(value) for value in row.values())
3168
+ parts.append(str(block.get("unit") or ""))
3169
+ return "\n".join(parts)
3170
+
3171
+
3172
+ def _plan_text(normalized_plan: DocumentPlan | None) -> str:
3173
+ if normalized_plan is None:
3174
+ return ""
3175
+ parts = [normalized_plan.title, normalized_plan.subtitle]
3176
+ parts.extend(normalized_plan.metadata.values())
3177
+ for block in normalized_plan.blocks:
3178
+ if block.type in {"heading", "paragraph", "memo"}:
3179
+ parts.extend(str(value) for value in block.data.values())
3180
+ elif block.type == "bullets":
3181
+ parts.extend(str(item) for item in block.data.get("items", []))
3182
+ elif block.type == "table":
3183
+ parts.append(_table_block_text(block.data))
3184
+ return "\n".join(part for part in parts if part)
3185
+
3186
+
3187
+ def _document_table_blocks(document: HwpxDocument) -> list[Mapping[str, Any]]:
3188
+ blocks: list[Mapping[str, Any]] = []
3189
+ previous_text = ""
3190
+ for paragraph in document.paragraphs:
3191
+ tables = list(getattr(paragraph, "tables", []))
3192
+ if tables:
3193
+ for table in tables:
3194
+ text_rows = [
3195
+ [
3196
+ _document_table_cell_text(cell)
3197
+ for cell in getattr(row, "cells", [])
3198
+ ]
3199
+ for row in getattr(table, "rows", [])
3200
+ ]
3201
+ text_rows = [row for row in text_rows if any(cell.strip() for cell in row)]
3202
+ max_column_count = max((len(row) for row in text_rows), default=0)
3203
+ header_row = text_rows[0] if _looks_like_table_header_row(text_rows) else []
3204
+ data_rows = text_rows[1:] if header_row else text_rows
3205
+ columns = [
3206
+ {
3207
+ "key": f"col{index}",
3208
+ "label": (
3209
+ header_row[index]
3210
+ if index < len(header_row)
3211
+ else f"col{index}"
3212
+ ),
3213
+ }
3214
+ for index in range(max_column_count)
3215
+ ]
3216
+ rows = [
3217
+ {f"col{index}": text for index, text in enumerate(row)}
3218
+ for row in data_rows
3219
+ ]
3220
+ blocks.append(
3221
+ {
3222
+ "caption": previous_text,
3223
+ "columns": columns,
3224
+ "rows": rows,
3225
+ }
3226
+ )
3227
+ previous_text = ""
3228
+ continue
3229
+
3230
+ text = str(getattr(paragraph, "text", "") or "").strip()
3231
+ if text:
3232
+ if _looks_like_unit_text(text):
3233
+ previous_text = ""
3234
+ continue
3235
+ previous_text = text
3236
+ return blocks
3237
+
3238
+
3239
+ def _looks_like_unit_text(text: str) -> bool:
3240
+ return text.startswith(("단위:", "단위:"))
3241
+
3242
+
3243
+ def _looks_like_table_header_row(text_rows: list[list[str]]) -> bool:
3244
+ if not text_rows:
3245
+ return False
3246
+ header_terms = {
3247
+ "단계",
3248
+ "기간",
3249
+ "세부 추진 내용",
3250
+ "담당",
3251
+ "월",
3252
+ "추진 내용",
3253
+ "항목",
3254
+ "금액",
3255
+ "비율",
3256
+ "비율(%)",
3257
+ "산출근거",
3258
+ "내용",
3259
+ "구분",
3260
+ }
3261
+ cells = [cell.strip() for cell in text_rows[0] if cell.strip()]
3262
+ hits = sum(
3263
+ 1
3264
+ for cell in cells
3265
+ if any(cell == term or (len(term) > 1 and term in cell) for term in header_terms)
3266
+ )
3267
+ return hits >= 2 or (len(cells) == 1 and hits == 1)
3268
+
3269
+
3270
+ def _document_table_cell_text(cell: Any) -> str:
3271
+ parts = [str(getattr(cell, "text", "") or "").strip()]
3272
+ for paragraph in getattr(cell, "paragraphs", []):
3273
+ text = str(getattr(paragraph, "text", "") or "").strip()
3274
+ if text:
3275
+ parts.append(text)
3276
+ return " ".join(part for part in parts if part)
3277
+
3278
+
3279
+ def _table_text(document: HwpxDocument) -> str:
3280
+ parts: list[str] = []
3281
+ for table in _iter_tables(document):
3282
+ for row in getattr(table, "rows", []):
3283
+ for cell in getattr(row, "cells", []):
3284
+ parts.append(str(getattr(cell, "text", "") or ""))
3285
+ for paragraph in getattr(cell, "paragraphs", []):
3286
+ text = str(getattr(paragraph, "text", "") or "").strip()
3287
+ if text:
3288
+ parts.append(text)
3289
+ return "\n".join(parts)
3290
+
3291
+
3292
+ def _contains_any(text: str, terms: Any) -> bool:
3293
+ return any(term in text for term in _string_list(terms))
3294
+
3295
+
3296
+ def _camel_to_snake(value: str) -> str:
3297
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", value).lower()
3298
+
3299
+
3300
+ def _iter_runs_deep(document: HwpxDocument) -> list[Any]:
3301
+ runs = list(document.iter_runs())
3302
+ for table in _iter_tables(document):
3303
+ for row in table.rows:
3304
+ for cell in row.cells:
3305
+ for paragraph in cell.paragraphs:
3306
+ runs.extend(paragraph.runs)
3307
+ return runs
3308
+
3309
+
3310
+ def _iter_tables(document: HwpxDocument) -> list[Any]:
3311
+ tables: list[Any] = []
3312
+ for paragraph in document.paragraphs:
3313
+ tables.extend(getattr(paragraph, "tables", []))
3314
+ return tables
3315
+
3316
+
3317
+ def _can_reopen(path: Path | None, package_payload: bytes) -> bool:
3318
+ try:
3319
+ doc = HwpxDocument.open(path if path is not None else package_payload)
3320
+ doc.close()
3321
+ return True
3322
+ except Exception:
3323
+ return False
3324
+
3325
+
3326
+ def _report_errors(report: Any) -> list[str]:
3327
+ for attr in ("errors", "issues", "messages"):
3328
+ value = getattr(report, attr, None)
3329
+ if value is None:
3330
+ continue
3331
+ try:
3332
+ return [str(item) for item in value]
3333
+ except TypeError:
3334
+ return [str(value)]
3335
+ return []
3336
+
3337
+
3338
+ def _report_issue_dicts(report: Any, *, kind: str) -> list[dict[str, Any]]:
3339
+ value = getattr(report, "issues", None)
3340
+ if value is None:
3341
+ value = getattr(report, "errors", None)
3342
+ if value is None:
3343
+ value = getattr(report, "messages", None)
3344
+ if value is None:
3345
+ return []
3346
+
3347
+ try:
3348
+ items = list(value)
3349
+ except TypeError:
3350
+ items = [value]
3351
+
3352
+ issue_dicts: list[dict[str, Any]] = []
3353
+ for item in items:
3354
+ message = str(getattr(item, "message", item))
3355
+ part = (
3356
+ getattr(item, "part_name", None)
3357
+ or getattr(item, "part", None)
3358
+ or "$"
3359
+ )
3360
+ entry: dict[str, Any] = {
3361
+ "part": str(part),
3362
+ "message": message,
3363
+ "level": str(getattr(item, "level", "error")),
3364
+ "suggestion": _validation_issue_suggestion(
3365
+ kind=kind,
3366
+ part=str(part),
3367
+ message=message,
3368
+ ),
3369
+ }
3370
+ line = getattr(item, "line", None)
3371
+ column = getattr(item, "column", None)
3372
+ if line is not None:
3373
+ entry["line"] = line
3374
+ if column is not None:
3375
+ entry["column"] = column
3376
+ issue_dicts.append(entry)
3377
+ return issue_dicts
3378
+
3379
+
3380
+ def _validation_issue_suggestion(*, kind: str, part: str, message: str) -> str:
3381
+ haystack = f"{part} {message}".lower()
3382
+ if "mimetype" in haystack or "zip_stored" in haystack or "first zip entry" in haystack:
3383
+ return "Re-save with python-hwpx or repack so mimetype is first and stored."
3384
+ if "manifest" in haystack or "version" in haystack or "content.hpf" in haystack:
3385
+ return "Inspect Contents/content.hpf and version.xml references."
3386
+ if kind == "schema":
3387
+ return "Inspect the reported part and regenerate the affected block from the document plan."
3388
+ return "Reopen and save with python-hwpx, then rerun validation."
3389
+
3390
+
3391
+ def _recovery_summary(
3392
+ *,
3393
+ plan_validation: dict[str, Any] | None,
3394
+ package_issues: list[dict[str, Any]],
3395
+ document_issues: list[dict[str, Any]],
3396
+ gate_results: Mapping[str, bool],
3397
+ ) -> dict[str, Any]:
3398
+ repair_hints: list[dict[str, str]] = []
3399
+ next_actions: list[str] = []
3400
+
3401
+ if plan_validation is not None:
3402
+ repair_hints.extend(plan_validation.get("repairHints", []))
3403
+ if not plan_validation.get("ok", False):
3404
+ next_actions.append(
3405
+ "Repair document_plan using repairHints, then rerun validate_document_plan."
3406
+ )
3407
+
3408
+ for issue in package_issues:
3409
+ suggestion = str(issue.get("suggestion") or "")
3410
+ if suggestion:
3411
+ level = str(issue.get("level") or "error")
3412
+ repair_hints.append(
3413
+ {
3414
+ "path": str(issue.get("part") or "$"),
3415
+ "code": "package_validation_issue",
3416
+ "action": "fix" if level == "error" else "review",
3417
+ "message": suggestion,
3418
+ }
3419
+ )
3420
+ package_error_issues = [
3421
+ issue for issue in package_issues if str(issue.get("level") or "error") == "error"
3422
+ ]
3423
+ package_warning_issues = [
3424
+ issue for issue in package_issues if str(issue.get("level") or "error") != "error"
3425
+ ]
3426
+ if package_error_issues:
3427
+ next_actions.append(
3428
+ "Resolve package validation issues and rerun inspect_document_authoring_quality."
3429
+ )
3430
+ elif package_warning_issues:
3431
+ next_actions.append("Review package validation warnings before handoff.")
3432
+
3433
+ for issue in document_issues:
3434
+ suggestion = str(issue.get("suggestion") or "")
3435
+ if suggestion:
3436
+ repair_hints.append(
3437
+ {
3438
+ "path": str(issue.get("part") or "$"),
3439
+ "code": "schema_validation_issue",
3440
+ "action": "fix",
3441
+ "message": suggestion,
3442
+ }
3443
+ )
3444
+ if document_issues:
3445
+ next_actions.append(
3446
+ "Resolve schema validation issues by regenerating the affected plan block, then rerun inspection."
3447
+ )
3448
+
3449
+ failed_gates = [
3450
+ name
3451
+ for name, passed in gate_results.items()
3452
+ if name != "visualReviewRequired" and not passed
3453
+ ]
3454
+ if failed_gates:
3455
+ next_actions.append(
3456
+ "Fix failed quality gates: " + ", ".join(failed_gates) + "."
3457
+ )
3458
+ if not next_actions:
3459
+ next_actions.append(
3460
+ "Structural checks passed; complete visual review before handoff if required."
3461
+ )
3462
+
3463
+ return {
3464
+ "repair_hints": repair_hints,
3465
+ "next_actions": next_actions,
3466
+ }
3467
+
3468
+
3469
+ def _required_text(raw: Mapping[str, Any], key: str, index: int) -> str:
3470
+ value = str(raw.get(key) or "").strip()
3471
+ if not value:
3472
+ raise ValueError(f"blocks[{index}].{key} is required")
3473
+ return value
3474
+
3475
+
3476
+ def _string_mapping(value: Mapping[str, Any]) -> dict[str, str]:
3477
+ return {str(key): str(item) for key, item in value.items()}
3478
+
3479
+
3480
+ def _string_list(value: Any) -> list[str]:
3481
+ if isinstance(value, str):
3482
+ return [value.strip()] if value.strip() else []
3483
+ if not isinstance(value, list):
3484
+ return []
3485
+ return [str(item).strip() for item in value if str(item).strip()]
3486
+
3487
+
3488
+ def _int_value(value: Any, *, default: int) -> int:
3489
+ try:
3490
+ return int(value)
3491
+ except (TypeError, ValueError):
3492
+ return default
3493
+
3494
+
3495
+ def _float_value(value: Any, *, default: float) -> float:
3496
+ try:
3497
+ return float(value)
3498
+ except (TypeError, ValueError):
3499
+ return default
3500
+
3501
+
3502
+ def _optional_number(value: Any) -> int | float | None:
3503
+ if value in (None, ""):
3504
+ return None
3505
+ try:
3506
+ number = float(value)
3507
+ except (TypeError, ValueError):
3508
+ return None
3509
+ if number.is_integer():
3510
+ return int(number)
3511
+ return number
3512
+
3513
+
3514
+ def _optional_bool(value: Any) -> bool | None:
3515
+ if value is None:
3516
+ return None
3517
+ return bool(value)
3518
+
3519
+
3520
+ def _optional_str(value: Any) -> str | None:
3521
+ if value is None:
3522
+ return None
3523
+ text = str(value)
3524
+ return text if text else None
3525
+
3526
+
3527
+ __all__ = [
3528
+ "AUTHORING_REPORT_VERSION",
3529
+ "DEFAULT_STYLE_PRESET",
3530
+ "DOCUMENT_PLAN_SCHEMA_VERSION",
3531
+ "DOCUMENT_PLAN_V2_SCHEMA_VERSION",
3532
+ "DocumentBlock",
3533
+ "DocumentPlan",
3534
+ "DocumentStylePreset",
3535
+ "PlanValidationIssue",
3536
+ "PlanValidationReport",
3537
+ "create_document_from_plan",
3538
+ "inspect_document_authoring_quality",
3539
+ "inspect_operating_plan_quality",
3540
+ "normalize_document_plan",
3541
+ "validate_document_plan",
3542
+ ]