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