python-hwpx 2.11.1__py3-none-any.whl → 2.15.0__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/__init__.py +10 -0
- hwpx/authoring.py +415 -24
- hwpx/builder/__init__.py +8 -1
- hwpx/builder/core.py +110 -2
- hwpx/builder/report.py +88 -0
- hwpx/conformance/__init__.py +54 -0
- hwpx/conformance/badges.py +198 -0
- hwpx/conformance/corpus/corpus.json +53 -0
- hwpx/conformance/corpus/meeting_summary.hwpx +0 -0
- hwpx/conformance/corpus/notice.hwpx +0 -0
- hwpx/conformance/corpus/report_table.hwpx +0 -0
- hwpx/conformance/corpus.py +260 -0
- hwpx/conformance/report.py +223 -0
- hwpx/conformance/roundtrip_batch.py +171 -0
- hwpx/conformance/runner.py +395 -0
- hwpx/design/__init__.py +30 -0
- hwpx/design/_support.py +144 -0
- hwpx/design/composer.py +282 -0
- hwpx/design/harvest.py +305 -0
- hwpx/design/plan.py +69 -0
- hwpx/design/profile.py +88 -0
- hwpx/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx/design/profiles/application_form/profile.json +25 -0
- hwpx/design/profiles/application_form/template.hwpx +0 -0
- hwpx/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx/design/profiles/official_notice/profile.json +25 -0
- hwpx/design/profiles/official_notice/template.hwpx +0 -0
- hwpx/design/profiles/report/fragments/body.xml +1 -0
- hwpx/design/profiles/report/fragments/heading.xml +1 -0
- hwpx/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx/design/profiles/report/fragments/title.xml +1 -0
- hwpx/design/profiles/report/profile.json +25 -0
- hwpx/design/profiles/report/template.hwpx +0 -0
- hwpx/design/validator.py +107 -0
- hwpx/document.py +265 -84
- hwpx/exam/__init__.py +22 -0
- hwpx/exam/compose.py +237 -0
- hwpx/exam/ir.py +41 -0
- hwpx/exam/measure.py +147 -0
- hwpx/exam/parser.py +145 -0
- hwpx/exam/profile.py +116 -0
- hwpx/form_fit/__init__.py +51 -0
- hwpx/form_fit/apply.py +96 -0
- hwpx/form_fit/engine.py +294 -0
- hwpx/form_fit/measure.py +369 -0
- hwpx/form_fit/policy.py +84 -0
- hwpx/form_fit/report.py +93 -0
- hwpx/form_fit/seal.py +451 -0
- hwpx/form_fit/wordbox.py +1212 -0
- hwpx/layout/__init__.py +36 -0
- hwpx/layout/lint.py +384 -0
- hwpx/layout/report.py +121 -0
- hwpx/opc/package.py +12 -5
- hwpx/oxml/_document_impl.py +302 -7
- hwpx/oxml/body.py +45 -0
- hwpx/oxml/canonical_defaults.py +95 -0
- hwpx/oxml/header.py +16 -2
- hwpx/oxml/namespaces.py +16 -3
- hwpx/oxml/utils.py +10 -2
- hwpx/patch.py +76 -13
- hwpx/quality/__init__.py +45 -0
- hwpx/quality/ledger.py +111 -0
- hwpx/quality/policy.py +95 -0
- hwpx/quality/report.py +228 -0
- hwpx/quality/save_pipeline.py +556 -0
- hwpx/template_formfit.py +5 -1
- hwpx/tools/id_integrity.py +4 -1
- hwpx/tools/idempotence.py +139 -0
- hwpx/tools/ir_equality.py +137 -0
- hwpx/tools/mail_merge.py +197 -4
- hwpx/tools/package_reconcile.py +72 -0
- hwpx/tools/package_validator.py +16 -6
- hwpx/tools/validator.py +6 -3
- hwpx/visual/__init__.py +78 -0
- hwpx/visual/_render_hwpx.ps1 +72 -0
- hwpx/visual/_render_hwpx_mac.applescript +222 -0
- hwpx/visual/detectors.py +152 -0
- hwpx/visual/diff.py +153 -0
- hwpx/visual/masks.py +51 -0
- hwpx/visual/oracle.py +577 -0
- hwpx/visual/report.py +47 -0
- {python_hwpx-2.11.1.dist-info → python_hwpx-2.15.0.dist-info}/METADATA +7 -1
- python_hwpx-2.15.0.dist-info/RECORD +149 -0
- {python_hwpx-2.11.1.dist-info → python_hwpx-2.15.0.dist-info}/entry_points.txt +1 -0
- python_hwpx-2.11.1.dist-info/RECORD +0 -80
- {python_hwpx-2.11.1.dist-info → python_hwpx-2.15.0.dist-info}/WHEEL +0 -0
- {python_hwpx-2.11.1.dist-info → python_hwpx-2.15.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-2.11.1.dist-info → python_hwpx-2.15.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-2.11.1.dist-info → python_hwpx-2.15.0.dist-info}/top_level.txt +0 -0
hwpx/__init__.py
CHANGED
|
@@ -94,12 +94,18 @@ from .authoring import (
|
|
|
94
94
|
PlanValidationIssue,
|
|
95
95
|
PlanValidationReport,
|
|
96
96
|
create_document_from_plan,
|
|
97
|
+
get_document_plan_schema,
|
|
97
98
|
inspect_document_authoring_quality,
|
|
98
99
|
inspect_operating_plan_quality,
|
|
99
100
|
normalize_document_plan,
|
|
100
101
|
validate_document_plan,
|
|
101
102
|
)
|
|
102
103
|
from .builder import approval_box
|
|
104
|
+
from .quality import (
|
|
105
|
+
QualityPolicy,
|
|
106
|
+
SavePipeline,
|
|
107
|
+
VisualCompleteReport,
|
|
108
|
+
)
|
|
103
109
|
from .template_formfit import (
|
|
104
110
|
TEMPLATE_FORMFIT_BASELINE_SCHEMA_VERSION,
|
|
105
111
|
TEMPLATE_FORMFIT_PLAN_SCHEMA_VERSION,
|
|
@@ -108,11 +114,15 @@ from .template_formfit import (
|
|
|
108
114
|
)
|
|
109
115
|
|
|
110
116
|
__all__ = [
|
|
117
|
+
"QualityPolicy",
|
|
118
|
+
"SavePipeline",
|
|
119
|
+
"VisualCompleteReport",
|
|
111
120
|
"__version__",
|
|
112
121
|
"AUTHORING_REPORT_VERSION",
|
|
113
122
|
"DEFAULT_NAMESPACES",
|
|
114
123
|
"DEFAULT_STYLE_PRESET",
|
|
115
124
|
"DOCUMENT_PLAN_SCHEMA_VERSION",
|
|
125
|
+
"get_document_plan_schema",
|
|
116
126
|
"DocumentBlock",
|
|
117
127
|
"DocumentPlan",
|
|
118
128
|
"DocumentStylePreset",
|
hwpx/authoring.py
CHANGED
|
@@ -30,6 +30,7 @@ from .builder import (
|
|
|
30
30
|
)
|
|
31
31
|
from .builder.core import Toc as BuilderToc
|
|
32
32
|
from .document import HwpxDocument
|
|
33
|
+
from .oxml.namespaces import HP as _HP
|
|
33
34
|
from .tools.package_validator import validate_package
|
|
34
35
|
from .tools.table_cleanup import normalize_cell_text
|
|
35
36
|
from .tools.advanced_generators import build_image_grid
|
|
@@ -48,7 +49,7 @@ DOCUMENT_PLAN_V2_SCHEMA_VERSION = "hwpx.document_plan.v2"
|
|
|
48
49
|
AUTHORING_REPORT_VERSION = "hwpx-authoring-quality-v1"
|
|
49
50
|
OPERATING_PLAN_QUALITY_VERSION = "operating-plan-quality-v1"
|
|
50
51
|
DEFAULT_STYLE_PRESET = "standard_korean_business"
|
|
51
|
-
_DEFAULT_TABLE_WIDTH =
|
|
52
|
+
_DEFAULT_TABLE_WIDTH = 45_000 # ~158.7mm (HWPUNIT): fits A4(210mm) content at 25mm margins (~160mm). 48000(~169mm) overflowed the right margin in Hancom.
|
|
52
53
|
_METADATA_LABELS = {
|
|
53
54
|
"organization": "기관",
|
|
54
55
|
"author": "작성자",
|
|
@@ -62,6 +63,10 @@ _SUPPORTED_STYLE_TOKENS = frozenset(
|
|
|
62
63
|
{"body", "title", "subtitle", "heading", "bullet", "table_header", "table_cell"}
|
|
63
64
|
)
|
|
64
65
|
_SUPPORTED_TABLE_PROFILES = frozenset({"government"})
|
|
66
|
+
_DEFAULT_PAGE_MARGIN_MM = 25
|
|
67
|
+
_TABLE_BORDER_COLOR = "#BFBFBF"
|
|
68
|
+
_TABLE_HEADER_FILL = "#F2F2F2"
|
|
69
|
+
_TABLE_CELL_MARGIN = "425"
|
|
65
70
|
_BOOLEAN_QUALITY_GATES = frozenset(
|
|
66
71
|
{"validatePackage", "validateDocument", "reopen", "visualReviewRequired"}
|
|
67
72
|
)
|
|
@@ -164,14 +169,23 @@ class DocumentStylePreset:
|
|
|
164
169
|
|
|
165
170
|
name: str = DEFAULT_STYLE_PRESET
|
|
166
171
|
title_bold: bool = True
|
|
167
|
-
subtitle_italic: bool =
|
|
172
|
+
subtitle_italic: bool = False
|
|
168
173
|
heading_bold: bool = True
|
|
169
|
-
heading_underline: bool =
|
|
174
|
+
heading_underline: bool = False
|
|
170
175
|
table_header_bold: bool = True
|
|
171
|
-
title_size: int =
|
|
176
|
+
title_size: int = 20
|
|
172
177
|
subtitle_size: int = 12
|
|
173
178
|
heading_size: int = 14
|
|
174
|
-
|
|
179
|
+
body_size: int = 11
|
|
180
|
+
meta_size: int = 10
|
|
181
|
+
font: str = "함초롬돋움"
|
|
182
|
+
title_color: str | None = "#1F3864"
|
|
183
|
+
heading_color: str | None = "#1F3864"
|
|
184
|
+
subtitle_color: str | None = "#595959"
|
|
185
|
+
meta_color: str | None = "#595959"
|
|
186
|
+
title_rule: bool = True
|
|
187
|
+
heading_rule: bool = True
|
|
188
|
+
rule_color: str = "#BFBFBF"
|
|
175
189
|
|
|
176
190
|
def ensure_tokens(self, document: HwpxDocument) -> dict[str, str]:
|
|
177
191
|
"""Create/reuse run styles and return semantic token IDs."""
|
|
@@ -181,22 +195,37 @@ class DocumentStylePreset:
|
|
|
181
195
|
bold=self.title_bold,
|
|
182
196
|
size=self.title_size,
|
|
183
197
|
font=self.font,
|
|
198
|
+
color=self.title_color,
|
|
184
199
|
),
|
|
185
200
|
"subtitle": document.ensure_run_style(
|
|
186
201
|
italic=self.subtitle_italic,
|
|
187
202
|
size=self.subtitle_size,
|
|
188
203
|
font=self.font,
|
|
204
|
+
color=self.subtitle_color,
|
|
189
205
|
),
|
|
190
206
|
"heading": document.ensure_run_style(
|
|
191
207
|
bold=self.heading_bold,
|
|
192
208
|
underline=self.heading_underline,
|
|
193
209
|
size=self.heading_size,
|
|
194
210
|
font=self.font,
|
|
211
|
+
color=self.heading_color,
|
|
212
|
+
),
|
|
213
|
+
"body": document.ensure_run_style(size=self.body_size, font=self.font),
|
|
214
|
+
"bullet": document.ensure_run_style(size=self.body_size, font=self.font),
|
|
215
|
+
"meta": document.ensure_run_style(
|
|
216
|
+
size=self.meta_size,
|
|
217
|
+
font=self.font,
|
|
218
|
+
color=self.meta_color,
|
|
219
|
+
),
|
|
220
|
+
"table_header": document.ensure_run_style(
|
|
221
|
+
bold=self.table_header_bold,
|
|
222
|
+
size=self.body_size,
|
|
223
|
+
font=self.font,
|
|
224
|
+
),
|
|
225
|
+
"table_cell": document.ensure_run_style(
|
|
226
|
+
size=self.body_size,
|
|
227
|
+
font=self.font,
|
|
195
228
|
),
|
|
196
|
-
"body": document.ensure_run_style(),
|
|
197
|
-
"bullet": document.ensure_run_style(),
|
|
198
|
-
"table_header": document.ensure_run_style(bold=self.table_header_bold),
|
|
199
|
-
"table_cell": document.ensure_run_style(),
|
|
200
229
|
}
|
|
201
230
|
|
|
202
231
|
|
|
@@ -236,6 +265,27 @@ def _plan_issue(
|
|
|
236
265
|
)
|
|
237
266
|
|
|
238
267
|
|
|
268
|
+
_PLAN_FAMILY_PREFIX = "hwpx.document_plan.v"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _is_forward_plan_version(version: str) -> bool:
|
|
272
|
+
"""True if *version* is a newer same-family plan schema (forward-compat).
|
|
273
|
+
|
|
274
|
+
e.g. ``hwpx.document_plan.v3`` when the latest known is v2 — validate
|
|
275
|
+
best-effort with a warning rather than hard-rejecting.
|
|
276
|
+
"""
|
|
277
|
+
if not version.startswith(_PLAN_FAMILY_PREFIX):
|
|
278
|
+
return False
|
|
279
|
+
suffix = version[len(_PLAN_FAMILY_PREFIX):]
|
|
280
|
+
if not suffix.isdigit():
|
|
281
|
+
return False
|
|
282
|
+
latest_known = max(
|
|
283
|
+
int(DOCUMENT_PLAN_SCHEMA_VERSION.rsplit("v", 1)[-1]),
|
|
284
|
+
int(DOCUMENT_PLAN_V2_SCHEMA_VERSION.rsplit("v", 1)[-1]),
|
|
285
|
+
)
|
|
286
|
+
return int(suffix) > latest_known
|
|
287
|
+
|
|
288
|
+
|
|
239
289
|
def _plan_validation_report(
|
|
240
290
|
issues: list[PlanValidationIssue],
|
|
241
291
|
*,
|
|
@@ -420,6 +470,54 @@ def _plan_repair_hints(issues: tuple[PlanValidationIssue, ...]) -> list[dict[str
|
|
|
420
470
|
return hints
|
|
421
471
|
|
|
422
472
|
|
|
473
|
+
DOCUMENT_PLAN_SCHEMA_ID = "https://airmang.github.io/hwpx-plugins/schemas/document_plan.schema.json"
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def get_document_plan_schema() -> dict[str, Any]:
|
|
477
|
+
"""Return a JSON Schema (draft 2020-12) for the declarative document plan.
|
|
478
|
+
|
|
479
|
+
Built live from the validator's own constants so it never drifts from the
|
|
480
|
+
accepted contract. Usable directly as an LLM Structured-Outputs / external
|
|
481
|
+
JSON-Schema-validation contract: it constrains the envelope (schemaVersion,
|
|
482
|
+
a non-empty ``blocks`` array, each block carrying a known ``type``) while
|
|
483
|
+
leaving block bodies open (``additionalProperties``) for forward-compat.
|
|
484
|
+
"""
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
488
|
+
"$id": DOCUMENT_PLAN_SCHEMA_ID,
|
|
489
|
+
"title": "HWPX Document Plan",
|
|
490
|
+
"type": "object",
|
|
491
|
+
"required": ["schemaVersion", "blocks"],
|
|
492
|
+
"additionalProperties": True,
|
|
493
|
+
"properties": {
|
|
494
|
+
"schemaVersion": {
|
|
495
|
+
"type": "string",
|
|
496
|
+
"enum": [DOCUMENT_PLAN_SCHEMA_VERSION, DOCUMENT_PLAN_V2_SCHEMA_VERSION],
|
|
497
|
+
"description": "Plan schema version. Newer same-family versions validate best-effort.",
|
|
498
|
+
},
|
|
499
|
+
"title": {"type": "string"},
|
|
500
|
+
"metadata": {"type": "object"},
|
|
501
|
+
"blocks": {
|
|
502
|
+
"type": "array",
|
|
503
|
+
"minItems": 1,
|
|
504
|
+
"items": {
|
|
505
|
+
"type": "object",
|
|
506
|
+
"required": ["type"],
|
|
507
|
+
"additionalProperties": True,
|
|
508
|
+
"properties": {
|
|
509
|
+
"type": {
|
|
510
|
+
"type": "string",
|
|
511
|
+
"enum": sorted(_SUPPORTED_BLOCK_TYPES),
|
|
512
|
+
"description": "Block kind. Body fields depend on the type.",
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
|
|
423
521
|
def validate_document_plan(plan: Mapping[str, Any]) -> PlanValidationReport:
|
|
424
522
|
"""Return validation errors for a ``hwpx.document_plan.v1`` mapping."""
|
|
425
523
|
|
|
@@ -443,6 +541,31 @@ def validate_document_plan(plan: Mapping[str, Any]) -> PlanValidationReport:
|
|
|
443
541
|
|
|
444
542
|
schema_version = str(plan.get("schemaVersion") or "").strip()
|
|
445
543
|
if schema_version not in {DOCUMENT_PLAN_SCHEMA_VERSION, DOCUMENT_PLAN_V2_SCHEMA_VERSION}:
|
|
544
|
+
if _is_forward_plan_version(schema_version):
|
|
545
|
+
# Forward-compat: a newer same-family version warns and validates as
|
|
546
|
+
# the latest known schema (best-effort) instead of hard-rejecting, so
|
|
547
|
+
# a plan emitted against a newer schema still generates. Unknown newer
|
|
548
|
+
# fields are simply ignored by the v2 validator.
|
|
549
|
+
issues.append(
|
|
550
|
+
_plan_issue(
|
|
551
|
+
"forward_schema_version",
|
|
552
|
+
"schemaVersion",
|
|
553
|
+
(
|
|
554
|
+
f"schemaVersion {schema_version!r} is newer than the latest "
|
|
555
|
+
f"known {DOCUMENT_PLAN_V2_SCHEMA_VERSION!r}; validating as "
|
|
556
|
+
"latest known (best-effort)."
|
|
557
|
+
),
|
|
558
|
+
severity="warning",
|
|
559
|
+
suggestion="Unknown newer fields are ignored; verify the output.",
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
v2_report = _validate_document_plan_v2(
|
|
563
|
+
plan, schema_version=DOCUMENT_PLAN_V2_SCHEMA_VERSION
|
|
564
|
+
)
|
|
565
|
+
return _plan_validation_report(
|
|
566
|
+
[*issues, *v2_report.issues],
|
|
567
|
+
schema_version=schema_version,
|
|
568
|
+
)
|
|
446
569
|
issues.append(
|
|
447
570
|
_plan_issue(
|
|
448
571
|
"invalid_schema_version",
|
|
@@ -714,12 +837,15 @@ def _validate_v2_block(raw_block: Any, *, path: str) -> list[PlanValidationIssue
|
|
|
714
837
|
issues.extend(_computed_field_issues(raw_block.get("text"), path=f"{path}.text"))
|
|
715
838
|
elif block_type == "paragraph":
|
|
716
839
|
issues.extend(_computed_field_issues(raw_block.get("text"), path=f"{path}.text"))
|
|
717
|
-
|
|
840
|
+
children = raw_block.get("children")
|
|
841
|
+
if children is None:
|
|
842
|
+
children = raw_block.get("runs")
|
|
843
|
+
for child_index, child in enumerate(children or []):
|
|
718
844
|
if isinstance(child, Mapping):
|
|
719
845
|
issues.extend(
|
|
720
846
|
_computed_field_issues(
|
|
721
847
|
child.get("text"),
|
|
722
|
-
path=f"{path}.
|
|
848
|
+
path=f"{path}.runs[{child_index}].text",
|
|
723
849
|
)
|
|
724
850
|
)
|
|
725
851
|
elif block_type in {"bullets", "bullet", "numbered_list", "numberedList"}:
|
|
@@ -764,32 +890,59 @@ def create_document_from_plan(
|
|
|
764
890
|
else DocumentStylePreset(name=str(preset or normalized.style_preset or DEFAULT_STYLE_PRESET))
|
|
765
891
|
)
|
|
766
892
|
document = HwpxDocument.new()
|
|
893
|
+
document.set_page_setup(
|
|
894
|
+
margins_mm={
|
|
895
|
+
"left": _DEFAULT_PAGE_MARGIN_MM,
|
|
896
|
+
"right": _DEFAULT_PAGE_MARGIN_MM,
|
|
897
|
+
"top": _DEFAULT_PAGE_MARGIN_MM,
|
|
898
|
+
"bottom": _DEFAULT_PAGE_MARGIN_MM,
|
|
899
|
+
}
|
|
900
|
+
)
|
|
767
901
|
tokens = style_preset.ensure_tokens(document)
|
|
768
902
|
builder_document = _lower_plan_to_builder_document(normalized)
|
|
769
903
|
|
|
770
904
|
if normalized.title:
|
|
771
|
-
document.add_paragraph(
|
|
905
|
+
paragraph = document.add_paragraph(
|
|
772
906
|
normalized.title,
|
|
773
907
|
char_pr_id_ref=tokens["title"],
|
|
774
908
|
inherit_style=False,
|
|
775
909
|
)
|
|
910
|
+
_format_para(
|
|
911
|
+
document,
|
|
912
|
+
paragraph,
|
|
913
|
+
alignment="center",
|
|
914
|
+
line_spacing=130,
|
|
915
|
+
after_pt=2,
|
|
916
|
+
bottom_border=style_preset.title_rule,
|
|
917
|
+
border_color=style_preset.rule_color,
|
|
918
|
+
)
|
|
776
919
|
if normalized.subtitle:
|
|
777
|
-
document.add_paragraph(
|
|
920
|
+
paragraph = document.add_paragraph(
|
|
778
921
|
normalized.subtitle,
|
|
779
922
|
char_pr_id_ref=tokens["subtitle"],
|
|
780
923
|
inherit_style=False,
|
|
781
924
|
)
|
|
925
|
+
_format_para(document, paragraph, line_spacing=130, after_pt=10)
|
|
782
926
|
|
|
783
927
|
if normalized.metadata:
|
|
784
|
-
document.add_paragraph(
|
|
928
|
+
paragraph = document.add_paragraph(
|
|
785
929
|
"문서 정보",
|
|
786
930
|
char_pr_id_ref=tokens["heading"],
|
|
787
931
|
inherit_style=False,
|
|
788
932
|
)
|
|
933
|
+
_format_para(
|
|
934
|
+
document,
|
|
935
|
+
paragraph,
|
|
936
|
+
line_spacing=150,
|
|
937
|
+
before_pt=14,
|
|
938
|
+
after_pt=4,
|
|
939
|
+
bottom_border=style_preset.heading_rule,
|
|
940
|
+
border_color=style_preset.rule_color,
|
|
941
|
+
)
|
|
789
942
|
_add_key_value_table(document, normalized.metadata, tokens)
|
|
790
943
|
|
|
791
944
|
for block in builder_document.sections[0].children:
|
|
792
|
-
_render_block(document, block, tokens)
|
|
945
|
+
_render_block(document, block, tokens, style_preset=style_preset)
|
|
793
946
|
|
|
794
947
|
return document
|
|
795
948
|
|
|
@@ -989,6 +1142,14 @@ def _validate_block(raw_block: Any, *, index: int) -> list[PlanValidationIssue]:
|
|
|
989
1142
|
elif block_type == "paragraph":
|
|
990
1143
|
issues.extend(_validate_paragraph_block(raw_block, path=path))
|
|
991
1144
|
issues.extend(_computed_field_issues(raw_block.get("text"), path=f"{path}.text"))
|
|
1145
|
+
for run_index, run in enumerate(raw_block.get("runs") or []):
|
|
1146
|
+
if isinstance(run, Mapping):
|
|
1147
|
+
issues.extend(
|
|
1148
|
+
_computed_field_issues(
|
|
1149
|
+
run.get("text"),
|
|
1150
|
+
path=f"{path}.runs[{run_index}].text",
|
|
1151
|
+
)
|
|
1152
|
+
)
|
|
992
1153
|
elif block_type == "bullets":
|
|
993
1154
|
items = _string_list(raw_block.get("items") or raw_block.get("bullets"))
|
|
994
1155
|
if not items:
|
|
@@ -1075,7 +1236,37 @@ def _validate_heading_block(raw_block: Mapping[str, Any], *, path: str) -> list[
|
|
|
1075
1236
|
|
|
1076
1237
|
|
|
1077
1238
|
def _validate_paragraph_block(raw_block: Mapping[str, Any], *, path: str) -> list[PlanValidationIssue]:
|
|
1078
|
-
issues
|
|
1239
|
+
issues: list[PlanValidationIssue] = []
|
|
1240
|
+
text = str(raw_block.get("text") or "").strip()
|
|
1241
|
+
runs = raw_block.get("runs")
|
|
1242
|
+
has_rich_runs = False
|
|
1243
|
+
if runs is not None:
|
|
1244
|
+
if not isinstance(runs, list):
|
|
1245
|
+
issues.append(
|
|
1246
|
+
_plan_issue(
|
|
1247
|
+
"invalid_runs",
|
|
1248
|
+
f"{path}.runs",
|
|
1249
|
+
f"{path}.runs must be a list of run objects",
|
|
1250
|
+
suggestion="Use runs=[{'text': '...', 'bold': true, 'color': '#1F3864'}].",
|
|
1251
|
+
)
|
|
1252
|
+
)
|
|
1253
|
+
else:
|
|
1254
|
+
for run_index, run in enumerate(runs):
|
|
1255
|
+
run_path = f"{path}.runs[{run_index}]"
|
|
1256
|
+
if not isinstance(run, Mapping):
|
|
1257
|
+
issues.append(
|
|
1258
|
+
_plan_issue(
|
|
1259
|
+
"invalid_run",
|
|
1260
|
+
run_path,
|
|
1261
|
+
f"{run_path} must be a mapping",
|
|
1262
|
+
suggestion="Use a run object with text and optional bold/color fields.",
|
|
1263
|
+
)
|
|
1264
|
+
)
|
|
1265
|
+
continue
|
|
1266
|
+
if str(run.get("text") or "").strip():
|
|
1267
|
+
has_rich_runs = True
|
|
1268
|
+
if not text and not has_rich_runs:
|
|
1269
|
+
issues.extend(_validate_required_text_fields(raw_block, path=path, fields=("text",)))
|
|
1079
1270
|
style = str(raw_block.get("style") or "body").strip() or "body"
|
|
1080
1271
|
if style not in _SUPPORTED_STYLE_TOKENS:
|
|
1081
1272
|
issues.append(
|
|
@@ -1314,12 +1505,21 @@ def _normalize_block(raw_block: Any, *, index: int) -> DocumentBlock:
|
|
|
1314
1505
|
return DocumentBlock("heading", {"level": level, "text": replace_computed_fields(text)})
|
|
1315
1506
|
|
|
1316
1507
|
if block_type == "paragraph":
|
|
1508
|
+
runs = _normalize_paragraph_runs(raw_block.get("runs"), index=index)
|
|
1509
|
+
text = (
|
|
1510
|
+
replace_computed_fields(str(raw_block.get("text") or ""))
|
|
1511
|
+
if runs
|
|
1512
|
+
else replace_computed_fields(_required_text(raw_block, "text", index))
|
|
1513
|
+
)
|
|
1514
|
+
data: dict[str, Any] = {
|
|
1515
|
+
"text": text,
|
|
1516
|
+
"style": str(raw_block.get("style") or "body").strip() or "body",
|
|
1517
|
+
}
|
|
1518
|
+
if runs:
|
|
1519
|
+
data["runs"] = runs
|
|
1317
1520
|
return DocumentBlock(
|
|
1318
1521
|
"paragraph",
|
|
1319
|
-
|
|
1320
|
-
"text": replace_computed_fields(_required_text(raw_block, "text", index)),
|
|
1321
|
-
"style": str(raw_block.get("style") or "body").strip() or "body",
|
|
1322
|
-
},
|
|
1522
|
+
data,
|
|
1323
1523
|
)
|
|
1324
1524
|
|
|
1325
1525
|
if block_type == "bullets":
|
|
@@ -1364,6 +1564,29 @@ def _normalize_block(raw_block: Any, *, index: int) -> DocumentBlock:
|
|
|
1364
1564
|
return DocumentBlock("page_break", {})
|
|
1365
1565
|
|
|
1366
1566
|
|
|
1567
|
+
def _normalize_paragraph_runs(value: Any, *, index: int) -> list[dict[str, Any]]:
|
|
1568
|
+
if value is None:
|
|
1569
|
+
return []
|
|
1570
|
+
if not isinstance(value, list):
|
|
1571
|
+
raise ValueError(f"blocks[{index}].runs must be a list")
|
|
1572
|
+
runs: list[dict[str, Any]] = []
|
|
1573
|
+
for run_index, raw_run in enumerate(value):
|
|
1574
|
+
if not isinstance(raw_run, Mapping):
|
|
1575
|
+
raise ValueError(f"blocks[{index}].runs[{run_index}] must be a mapping")
|
|
1576
|
+
text = replace_computed_fields(str(raw_run.get("text") or ""))
|
|
1577
|
+
if not text:
|
|
1578
|
+
continue
|
|
1579
|
+
run: dict[str, Any] = {"text": text}
|
|
1580
|
+
if "bold" in raw_run:
|
|
1581
|
+
run["bold"] = bool(raw_run.get("bold"))
|
|
1582
|
+
if "color" in raw_run:
|
|
1583
|
+
color = _optional_str(raw_run.get("color"))
|
|
1584
|
+
if color is not None:
|
|
1585
|
+
run["color"] = color
|
|
1586
|
+
runs.append(run)
|
|
1587
|
+
return runs
|
|
1588
|
+
|
|
1589
|
+
|
|
1367
1590
|
def _normalize_v2_builder_document(plan: Mapping[str, Any]) -> BuilderDocument:
|
|
1368
1591
|
metadata = plan.get("metadata") or {}
|
|
1369
1592
|
builder_metadata = None
|
|
@@ -1491,9 +1714,12 @@ def _normalize_v2_block(raw_block: Any, *, path: str) -> Any:
|
|
|
1491
1714
|
text=replace_computed_fields(str(raw_block.get("text") or "")),
|
|
1492
1715
|
)
|
|
1493
1716
|
if block_type == "paragraph":
|
|
1717
|
+
raw_children = raw_block.get("children")
|
|
1718
|
+
if raw_children is None:
|
|
1719
|
+
raw_children = raw_block.get("runs")
|
|
1494
1720
|
children = tuple(
|
|
1495
1721
|
child
|
|
1496
|
-
for child in (_normalize_v2_paragraph_child(child) for child in
|
|
1722
|
+
for child in (_normalize_v2_paragraph_child(child) for child in raw_children or [])
|
|
1497
1723
|
if isinstance(child, BuilderRun)
|
|
1498
1724
|
)
|
|
1499
1725
|
return BuilderParagraph(
|
|
@@ -1639,6 +1865,15 @@ def _block_to_builder_nodes(block: DocumentBlock) -> tuple[Any, ...]:
|
|
|
1639
1865
|
),
|
|
1640
1866
|
)
|
|
1641
1867
|
if block.type == "paragraph":
|
|
1868
|
+
runs = block.data.get("runs") or []
|
|
1869
|
+
if runs:
|
|
1870
|
+
return (
|
|
1871
|
+
BuilderParagraph(
|
|
1872
|
+
text=str(block.data.get("text") or ""),
|
|
1873
|
+
children=tuple(_builder_run_from_plan(run) for run in runs),
|
|
1874
|
+
style=str(block.data.get("style") or "body"),
|
|
1875
|
+
),
|
|
1876
|
+
)
|
|
1642
1877
|
return (
|
|
1643
1878
|
BuilderParagraph(
|
|
1644
1879
|
text=str(block.data["text"]),
|
|
@@ -1675,6 +1910,14 @@ def _block_to_builder_nodes(block: DocumentBlock) -> tuple[Any, ...]:
|
|
|
1675
1910
|
raise ValueError(f"unsupported block type: {block.type!r}")
|
|
1676
1911
|
|
|
1677
1912
|
|
|
1913
|
+
def _builder_run_from_plan(run: Mapping[str, Any]) -> BuilderRun:
|
|
1914
|
+
return BuilderRun(
|
|
1915
|
+
text=str(run.get("text") or ""),
|
|
1916
|
+
bold=bool(run.get("bold", False)),
|
|
1917
|
+
color=_optional_str(run.get("color")),
|
|
1918
|
+
)
|
|
1919
|
+
|
|
1920
|
+
|
|
1678
1921
|
def _plan_table_column_widths(columns: list[dict[str, Any]]) -> list[int]:
|
|
1679
1922
|
total = sum(max(int(column.get("widthWeight", 1)), 1) for column in columns)
|
|
1680
1923
|
if total <= 0:
|
|
@@ -1740,34 +1983,134 @@ def _normalize_table_cell_value(value: Any) -> str:
|
|
|
1740
1983
|
return normalize_cell_text(value)
|
|
1741
1984
|
|
|
1742
1985
|
|
|
1986
|
+
def _format_para(
|
|
1987
|
+
document: HwpxDocument,
|
|
1988
|
+
paragraph: Any,
|
|
1989
|
+
*,
|
|
1990
|
+
alignment: str | None = None,
|
|
1991
|
+
line_spacing: int | None = None,
|
|
1992
|
+
before_pt: float | None = None,
|
|
1993
|
+
after_pt: float | None = None,
|
|
1994
|
+
bottom_border: bool = False,
|
|
1995
|
+
border_color: str = "#BFBFBF",
|
|
1996
|
+
) -> None:
|
|
1997
|
+
"""Apply breathing-room paragraph spacing to a freshly added paragraph.
|
|
1998
|
+
|
|
1999
|
+
Uses the public ``set_paragraph_format`` so unit conversion and paraPr
|
|
2000
|
+
deduplication are handled by the engine. Failures are non-fatal: spacing is
|
|
2001
|
+
a presentation nicety, never a reason to abort document generation.
|
|
2002
|
+
"""
|
|
2003
|
+
|
|
2004
|
+
kwargs: dict[str, Any] = {}
|
|
2005
|
+
if alignment is not None:
|
|
2006
|
+
kwargs["alignment"] = alignment
|
|
2007
|
+
if line_spacing is not None:
|
|
2008
|
+
kwargs["line_spacing_percent"] = line_spacing
|
|
2009
|
+
if before_pt is not None:
|
|
2010
|
+
kwargs["spacing_before_pt"] = before_pt
|
|
2011
|
+
if after_pt is not None:
|
|
2012
|
+
kwargs["spacing_after_pt"] = after_pt
|
|
2013
|
+
if bottom_border:
|
|
2014
|
+
kwargs["bottom_border"] = True
|
|
2015
|
+
kwargs["border_color"] = border_color
|
|
2016
|
+
if not kwargs:
|
|
2017
|
+
return
|
|
2018
|
+
try:
|
|
2019
|
+
index = document.paragraphs.index(paragraph)
|
|
2020
|
+
document.set_paragraph_format(paragraph_index=index, **kwargs)
|
|
2021
|
+
except (ValueError, KeyError):
|
|
2022
|
+
return
|
|
2023
|
+
|
|
2024
|
+
|
|
2025
|
+
def _add_rich_runs(
|
|
2026
|
+
document: HwpxDocument,
|
|
2027
|
+
paragraph: Any,
|
|
2028
|
+
runs: Any,
|
|
2029
|
+
*,
|
|
2030
|
+
base_char_pr_id: str,
|
|
2031
|
+
) -> None:
|
|
2032
|
+
for run in runs:
|
|
2033
|
+
if not isinstance(run, BuilderRun):
|
|
2034
|
+
raise ValueError(f"unsupported paragraph child: {type(run).__name__}")
|
|
2035
|
+
char_pr_id = document.ensure_run_style(
|
|
2036
|
+
bold=bool(run.bold),
|
|
2037
|
+
italic=bool(run.italic),
|
|
2038
|
+
underline=bool(run.underline),
|
|
2039
|
+
color=run.color,
|
|
2040
|
+
font=run.font,
|
|
2041
|
+
size=run.size,
|
|
2042
|
+
highlight=run.highlight,
|
|
2043
|
+
strike=run.strike,
|
|
2044
|
+
base_char_pr_id=base_char_pr_id,
|
|
2045
|
+
)
|
|
2046
|
+
paragraph.add_run(str(run.text or ""), char_pr_id_ref=char_pr_id)
|
|
2047
|
+
|
|
2048
|
+
|
|
1743
2049
|
def _render_block(
|
|
1744
2050
|
document: HwpxDocument,
|
|
1745
2051
|
block: Any,
|
|
1746
2052
|
tokens: Mapping[str, str],
|
|
2053
|
+
*,
|
|
2054
|
+
style_preset: DocumentStylePreset,
|
|
1747
2055
|
) -> None:
|
|
1748
2056
|
if isinstance(block, BuilderHeading):
|
|
1749
|
-
document.add_paragraph(
|
|
2057
|
+
paragraph = document.add_paragraph(
|
|
1750
2058
|
block.text,
|
|
1751
2059
|
char_pr_id_ref=tokens["heading"],
|
|
1752
2060
|
inherit_style=False,
|
|
1753
2061
|
**_outline_style_refs(document, block.level),
|
|
1754
2062
|
)
|
|
2063
|
+
_format_para(
|
|
2064
|
+
document,
|
|
2065
|
+
paragraph,
|
|
2066
|
+
line_spacing=150,
|
|
2067
|
+
before_pt=14,
|
|
2068
|
+
after_pt=4,
|
|
2069
|
+
bottom_border=style_preset.heading_rule,
|
|
2070
|
+
border_color=style_preset.rule_color,
|
|
2071
|
+
)
|
|
1755
2072
|
return
|
|
1756
2073
|
if isinstance(block, BuilderParagraph):
|
|
1757
2074
|
style = str(block.style or "body")
|
|
1758
|
-
|
|
2075
|
+
if block.children:
|
|
2076
|
+
paragraph = document.add_paragraph("", include_run=False, inherit_style=False)
|
|
2077
|
+
_add_rich_runs(
|
|
2078
|
+
document,
|
|
2079
|
+
paragraph,
|
|
2080
|
+
block.children,
|
|
2081
|
+
base_char_pr_id=tokens.get(style, tokens["body"]),
|
|
2082
|
+
)
|
|
2083
|
+
_format_para(
|
|
2084
|
+
document,
|
|
2085
|
+
paragraph,
|
|
2086
|
+
line_spacing=165,
|
|
2087
|
+
after_pt=4,
|
|
2088
|
+
bottom_border=style == "heading" and style_preset.heading_rule,
|
|
2089
|
+
border_color=style_preset.rule_color,
|
|
2090
|
+
)
|
|
2091
|
+
return
|
|
2092
|
+
paragraph = document.add_paragraph(
|
|
1759
2093
|
block.text,
|
|
1760
2094
|
char_pr_id_ref=tokens.get(style, tokens["body"]),
|
|
1761
2095
|
inherit_style=False,
|
|
1762
2096
|
)
|
|
2097
|
+
_format_para(
|
|
2098
|
+
document,
|
|
2099
|
+
paragraph,
|
|
2100
|
+
line_spacing=165,
|
|
2101
|
+
after_pt=4,
|
|
2102
|
+
bottom_border=style == "heading" and style_preset.heading_rule,
|
|
2103
|
+
border_color=style_preset.rule_color,
|
|
2104
|
+
)
|
|
1763
2105
|
return
|
|
1764
2106
|
if isinstance(block, BuilderBullet):
|
|
1765
2107
|
for item in block.items:
|
|
1766
|
-
document.add_paragraph(
|
|
2108
|
+
paragraph = document.add_paragraph(
|
|
1767
2109
|
f"• {item}",
|
|
1768
2110
|
char_pr_id_ref=tokens["bullet"],
|
|
1769
2111
|
inherit_style=False,
|
|
1770
2112
|
)
|
|
2113
|
+
_format_para(document, paragraph, line_spacing=150, after_pt=2)
|
|
1771
2114
|
return
|
|
1772
2115
|
if isinstance(block, BuilderTable):
|
|
1773
2116
|
_add_builder_table(document, block, tokens)
|
|
@@ -1848,6 +2191,7 @@ def _add_plan_table(
|
|
|
1848
2191
|
str(row.get(column["key"], "")),
|
|
1849
2192
|
char_pr_id_ref=tokens["table_cell"],
|
|
1850
2193
|
)
|
|
2194
|
+
_style_plan_table(document, table, header_fill=_TABLE_HEADER_FILL)
|
|
1851
2195
|
|
|
1852
2196
|
|
|
1853
2197
|
def _add_builder_table(
|
|
@@ -1888,6 +2232,12 @@ def _add_builder_table(
|
|
|
1888
2232
|
str(value),
|
|
1889
2233
|
char_pr_id_ref=tokens["table_cell"],
|
|
1890
2234
|
)
|
|
2235
|
+
_style_plan_table(
|
|
2236
|
+
document,
|
|
2237
|
+
table,
|
|
2238
|
+
header_fill=table_node.header_shading or _TABLE_HEADER_FILL,
|
|
2239
|
+
header_rows=1 if table_node.header else 0,
|
|
2240
|
+
)
|
|
1891
2241
|
|
|
1892
2242
|
|
|
1893
2243
|
def _set_table_cell_text(
|
|
@@ -1904,6 +2254,47 @@ def _set_table_cell_text(
|
|
|
1904
2254
|
paragraph.char_pr_id_ref = char_pr_id_ref
|
|
1905
2255
|
|
|
1906
2256
|
|
|
2257
|
+
def _style_plan_table(
|
|
2258
|
+
document: HwpxDocument,
|
|
2259
|
+
table: Any,
|
|
2260
|
+
*,
|
|
2261
|
+
header_fill: str,
|
|
2262
|
+
header_rows: int = 1,
|
|
2263
|
+
) -> None:
|
|
2264
|
+
border_fill_id = document.ensure_border_fill(border_color=_TABLE_BORDER_COLOR)
|
|
2265
|
+
header_fill_id = document.ensure_border_fill(
|
|
2266
|
+
border_color=_TABLE_BORDER_COLOR,
|
|
2267
|
+
fill_color=header_fill,
|
|
2268
|
+
)
|
|
2269
|
+
table.element.set("borderFillIDRef", border_fill_id)
|
|
2270
|
+
center_para_pr_id: str | None = None
|
|
2271
|
+
if header_rows and document.oxml.headers:
|
|
2272
|
+
center_para_pr_id = document.oxml.headers[0].ensure_paragraph_format(alignment="center")
|
|
2273
|
+
|
|
2274
|
+
for row_index, row in enumerate(table.rows):
|
|
2275
|
+
for cell in row.cells:
|
|
2276
|
+
is_header = row_index < header_rows
|
|
2277
|
+
cell.element.set("borderFillIDRef", header_fill_id if is_header else border_fill_id)
|
|
2278
|
+
cell.element.set("hasMargin", "1")
|
|
2279
|
+
_set_cell_margin(cell)
|
|
2280
|
+
sublist = cell.element.find(f"{_HP}subList")
|
|
2281
|
+
if sublist is not None:
|
|
2282
|
+
sublist.set("vertAlign", "CENTER")
|
|
2283
|
+
if is_header and center_para_pr_id is not None:
|
|
2284
|
+
for paragraph in cell.paragraphs:
|
|
2285
|
+
paragraph.para_pr_id_ref = center_para_pr_id
|
|
2286
|
+
table.mark_dirty()
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
def _set_cell_margin(cell: Any) -> None:
|
|
2290
|
+
margin = cell.element.find(f"{_HP}cellMargin")
|
|
2291
|
+
if margin is None:
|
|
2292
|
+
margin = cell.element.makeelement(f"{_HP}cellMargin", {})
|
|
2293
|
+
cell.element.append(margin)
|
|
2294
|
+
for side in ("left", "right", "top", "bottom"):
|
|
2295
|
+
margin.set(side, _TABLE_CELL_MARGIN)
|
|
2296
|
+
|
|
2297
|
+
|
|
1907
2298
|
def _apply_column_widths(table: Any, columns: list[dict[str, Any]]) -> None:
|
|
1908
2299
|
total = sum(max(int(column.get("widthWeight", 1)), 1) for column in columns)
|
|
1909
2300
|
if total <= 0:
|