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