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.
Files changed (95) hide show
  1. hwpx/__init__.py +10 -0
  2. hwpx/authoring.py +415 -24
  3. hwpx/builder/__init__.py +8 -1
  4. hwpx/builder/core.py +110 -2
  5. hwpx/builder/report.py +88 -0
  6. hwpx/conformance/__init__.py +54 -0
  7. hwpx/conformance/badges.py +198 -0
  8. hwpx/conformance/corpus/corpus.json +53 -0
  9. hwpx/conformance/corpus/meeting_summary.hwpx +0 -0
  10. hwpx/conformance/corpus/notice.hwpx +0 -0
  11. hwpx/conformance/corpus/report_table.hwpx +0 -0
  12. hwpx/conformance/corpus.py +260 -0
  13. hwpx/conformance/report.py +223 -0
  14. hwpx/conformance/roundtrip_batch.py +171 -0
  15. hwpx/conformance/runner.py +395 -0
  16. hwpx/design/__init__.py +30 -0
  17. hwpx/design/_support.py +144 -0
  18. hwpx/design/composer.py +282 -0
  19. hwpx/design/harvest.py +305 -0
  20. hwpx/design/plan.py +69 -0
  21. hwpx/design/profile.py +88 -0
  22. hwpx/design/profiles/application_form/fragments/body.xml +1 -0
  23. hwpx/design/profiles/application_form/fragments/heading.xml +1 -0
  24. hwpx/design/profiles/application_form/fragments/info_table.xml +1 -0
  25. hwpx/design/profiles/application_form/fragments/title.xml +1 -0
  26. hwpx/design/profiles/application_form/profile.json +25 -0
  27. hwpx/design/profiles/application_form/template.hwpx +0 -0
  28. hwpx/design/profiles/official_notice/fragments/body.xml +1 -0
  29. hwpx/design/profiles/official_notice/fragments/heading.xml +1 -0
  30. hwpx/design/profiles/official_notice/fragments/info_table.xml +1 -0
  31. hwpx/design/profiles/official_notice/fragments/title.xml +1 -0
  32. hwpx/design/profiles/official_notice/profile.json +25 -0
  33. hwpx/design/profiles/official_notice/template.hwpx +0 -0
  34. hwpx/design/profiles/report/fragments/body.xml +1 -0
  35. hwpx/design/profiles/report/fragments/heading.xml +1 -0
  36. hwpx/design/profiles/report/fragments/info_table.xml +1 -0
  37. hwpx/design/profiles/report/fragments/title.xml +1 -0
  38. hwpx/design/profiles/report/profile.json +25 -0
  39. hwpx/design/profiles/report/template.hwpx +0 -0
  40. hwpx/design/validator.py +107 -0
  41. hwpx/document.py +265 -84
  42. hwpx/exam/__init__.py +22 -0
  43. hwpx/exam/compose.py +237 -0
  44. hwpx/exam/ir.py +41 -0
  45. hwpx/exam/measure.py +147 -0
  46. hwpx/exam/parser.py +145 -0
  47. hwpx/exam/profile.py +116 -0
  48. hwpx/form_fit/__init__.py +51 -0
  49. hwpx/form_fit/apply.py +96 -0
  50. hwpx/form_fit/engine.py +294 -0
  51. hwpx/form_fit/measure.py +369 -0
  52. hwpx/form_fit/policy.py +84 -0
  53. hwpx/form_fit/report.py +93 -0
  54. hwpx/form_fit/seal.py +451 -0
  55. hwpx/form_fit/wordbox.py +1212 -0
  56. hwpx/layout/__init__.py +36 -0
  57. hwpx/layout/lint.py +384 -0
  58. hwpx/layout/report.py +121 -0
  59. hwpx/opc/package.py +12 -5
  60. hwpx/oxml/_document_impl.py +302 -7
  61. hwpx/oxml/body.py +45 -0
  62. hwpx/oxml/canonical_defaults.py +95 -0
  63. hwpx/oxml/header.py +16 -2
  64. hwpx/oxml/namespaces.py +16 -3
  65. hwpx/oxml/utils.py +10 -2
  66. hwpx/patch.py +76 -13
  67. hwpx/quality/__init__.py +45 -0
  68. hwpx/quality/ledger.py +111 -0
  69. hwpx/quality/policy.py +95 -0
  70. hwpx/quality/report.py +228 -0
  71. hwpx/quality/save_pipeline.py +556 -0
  72. hwpx/template_formfit.py +5 -1
  73. hwpx/tools/id_integrity.py +4 -1
  74. hwpx/tools/idempotence.py +139 -0
  75. hwpx/tools/ir_equality.py +137 -0
  76. hwpx/tools/mail_merge.py +197 -4
  77. hwpx/tools/package_reconcile.py +72 -0
  78. hwpx/tools/package_validator.py +16 -6
  79. hwpx/tools/validator.py +6 -3
  80. hwpx/visual/__init__.py +78 -0
  81. hwpx/visual/_render_hwpx.ps1 +72 -0
  82. hwpx/visual/_render_hwpx_mac.applescript +222 -0
  83. hwpx/visual/detectors.py +152 -0
  84. hwpx/visual/diff.py +153 -0
  85. hwpx/visual/masks.py +51 -0
  86. hwpx/visual/oracle.py +577 -0
  87. hwpx/visual/report.py +47 -0
  88. {python_hwpx-2.11.1.dist-info → python_hwpx-2.15.0.dist-info}/METADATA +7 -1
  89. python_hwpx-2.15.0.dist-info/RECORD +149 -0
  90. {python_hwpx-2.11.1.dist-info → python_hwpx-2.15.0.dist-info}/entry_points.txt +1 -0
  91. python_hwpx-2.11.1.dist-info/RECORD +0 -80
  92. {python_hwpx-2.11.1.dist-info → python_hwpx-2.15.0.dist-info}/WHEEL +0 -0
  93. {python_hwpx-2.11.1.dist-info → python_hwpx-2.15.0.dist-info}/licenses/LICENSE +0 -0
  94. {python_hwpx-2.11.1.dist-info → python_hwpx-2.15.0.dist-info}/licenses/NOTICE +0 -0
  95. {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 = 48_000
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 = True
172
+ subtitle_italic: bool = False
168
173
  heading_bold: bool = True
169
- heading_underline: bool = True
174
+ heading_underline: bool = False
170
175
  table_header_bold: bool = True
171
- title_size: int = 18
176
+ title_size: int = 20
172
177
  subtitle_size: int = 12
173
178
  heading_size: int = 14
174
- font: str = "함초롬바탕"
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
- for child_index, child in enumerate(raw_block.get("children") or []):
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}.children[{child_index}].text",
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 = _validate_required_text_fields(raw_block, path=path, fields=("text",))
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 raw_block.get("children") or [])
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
- document.add_paragraph(
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: