python-hwpx-automation 6.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
]
|