python-hwpx 2.11.1__py3-none-any.whl → 2.13.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 (72) hide show
  1. hwpx/__init__.py +8 -0
  2. hwpx/authoring.py +321 -24
  3. hwpx/builder/core.py +5 -1
  4. hwpx/builder/report.py +8 -0
  5. hwpx/conformance/__init__.py +54 -0
  6. hwpx/conformance/badges.py +198 -0
  7. hwpx/conformance/corpus/corpus.json +53 -0
  8. hwpx/conformance/corpus/meeting_summary.hwpx +0 -0
  9. hwpx/conformance/corpus/notice.hwpx +0 -0
  10. hwpx/conformance/corpus/report_table.hwpx +0 -0
  11. hwpx/conformance/corpus.py +260 -0
  12. hwpx/conformance/report.py +223 -0
  13. hwpx/conformance/runner.py +395 -0
  14. hwpx/design/__init__.py +30 -0
  15. hwpx/design/_support.py +144 -0
  16. hwpx/design/composer.py +282 -0
  17. hwpx/design/harvest.py +305 -0
  18. hwpx/design/plan.py +69 -0
  19. hwpx/design/profile.py +88 -0
  20. hwpx/design/profiles/application_form/fragments/body.xml +1 -0
  21. hwpx/design/profiles/application_form/fragments/heading.xml +1 -0
  22. hwpx/design/profiles/application_form/fragments/info_table.xml +1 -0
  23. hwpx/design/profiles/application_form/fragments/title.xml +1 -0
  24. hwpx/design/profiles/application_form/profile.json +25 -0
  25. hwpx/design/profiles/application_form/template.hwpx +0 -0
  26. hwpx/design/profiles/official_notice/fragments/body.xml +1 -0
  27. hwpx/design/profiles/official_notice/fragments/heading.xml +1 -0
  28. hwpx/design/profiles/official_notice/fragments/info_table.xml +1 -0
  29. hwpx/design/profiles/official_notice/fragments/title.xml +1 -0
  30. hwpx/design/profiles/official_notice/profile.json +25 -0
  31. hwpx/design/profiles/official_notice/template.hwpx +0 -0
  32. hwpx/design/profiles/report/fragments/body.xml +1 -0
  33. hwpx/design/profiles/report/fragments/heading.xml +1 -0
  34. hwpx/design/profiles/report/fragments/info_table.xml +1 -0
  35. hwpx/design/profiles/report/fragments/title.xml +1 -0
  36. hwpx/design/profiles/report/profile.json +25 -0
  37. hwpx/design/profiles/report/template.hwpx +0 -0
  38. hwpx/design/validator.py +107 -0
  39. hwpx/document.py +244 -81
  40. hwpx/form_fit/__init__.py +51 -0
  41. hwpx/form_fit/apply.py +96 -0
  42. hwpx/form_fit/engine.py +294 -0
  43. hwpx/form_fit/measure.py +369 -0
  44. hwpx/form_fit/policy.py +84 -0
  45. hwpx/form_fit/report.py +93 -0
  46. hwpx/layout/__init__.py +36 -0
  47. hwpx/layout/lint.py +384 -0
  48. hwpx/layout/report.py +121 -0
  49. hwpx/oxml/_document_impl.py +242 -1
  50. hwpx/patch.py +76 -13
  51. hwpx/quality/__init__.py +45 -0
  52. hwpx/quality/ledger.py +111 -0
  53. hwpx/quality/policy.py +95 -0
  54. hwpx/quality/report.py +228 -0
  55. hwpx/quality/save_pipeline.py +556 -0
  56. hwpx/template_formfit.py +5 -1
  57. hwpx/visual/__init__.py +78 -0
  58. hwpx/visual/_render_hwpx.ps1 +72 -0
  59. hwpx/visual/_render_hwpx_mac.applescript +222 -0
  60. hwpx/visual/detectors.py +152 -0
  61. hwpx/visual/diff.py +153 -0
  62. hwpx/visual/masks.py +51 -0
  63. hwpx/visual/oracle.py +505 -0
  64. hwpx/visual/report.py +47 -0
  65. {python_hwpx-2.11.1.dist-info → python_hwpx-2.13.0.dist-info}/METADATA +5 -1
  66. python_hwpx-2.13.0.dist-info/RECORD +136 -0
  67. {python_hwpx-2.11.1.dist-info → python_hwpx-2.13.0.dist-info}/entry_points.txt +1 -0
  68. python_hwpx-2.11.1.dist-info/RECORD +0 -80
  69. {python_hwpx-2.11.1.dist-info → python_hwpx-2.13.0.dist-info}/WHEEL +0 -0
  70. {python_hwpx-2.11.1.dist-info → python_hwpx-2.13.0.dist-info}/licenses/LICENSE +0 -0
  71. {python_hwpx-2.11.1.dist-info → python_hwpx-2.13.0.dist-info}/licenses/NOTICE +0 -0
  72. {python_hwpx-2.11.1.dist-info → python_hwpx-2.13.0.dist-info}/top_level.txt +0 -0
hwpx/__init__.py CHANGED
@@ -100,6 +100,11 @@ from .authoring import (
100
100
  validate_document_plan,
101
101
  )
102
102
  from .builder import approval_box
103
+ from .quality import (
104
+ QualityPolicy,
105
+ SavePipeline,
106
+ VisualCompleteReport,
107
+ )
103
108
  from .template_formfit import (
104
109
  TEMPLATE_FORMFIT_BASELINE_SCHEMA_VERSION,
105
110
  TEMPLATE_FORMFIT_PLAN_SCHEMA_VERSION,
@@ -108,6 +113,9 @@ from .template_formfit import (
108
113
  )
109
114
 
110
115
  __all__ = [
116
+ "QualityPolicy",
117
+ "SavePipeline",
118
+ "VisualCompleteReport",
111
119
  "__version__",
112
120
  "AUTHORING_REPORT_VERSION",
113
121
  "DEFAULT_NAMESPACES",
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
 
@@ -714,12 +743,15 @@ def _validate_v2_block(raw_block: Any, *, path: str) -> list[PlanValidationIssue
714
743
  issues.extend(_computed_field_issues(raw_block.get("text"), path=f"{path}.text"))
715
744
  elif block_type == "paragraph":
716
745
  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 []):
746
+ children = raw_block.get("children")
747
+ if children is None:
748
+ children = raw_block.get("runs")
749
+ for child_index, child in enumerate(children or []):
718
750
  if isinstance(child, Mapping):
719
751
  issues.extend(
720
752
  _computed_field_issues(
721
753
  child.get("text"),
722
- path=f"{path}.children[{child_index}].text",
754
+ path=f"{path}.runs[{child_index}].text",
723
755
  )
724
756
  )
725
757
  elif block_type in {"bullets", "bullet", "numbered_list", "numberedList"}:
@@ -764,32 +796,59 @@ def create_document_from_plan(
764
796
  else DocumentStylePreset(name=str(preset or normalized.style_preset or DEFAULT_STYLE_PRESET))
765
797
  )
766
798
  document = HwpxDocument.new()
799
+ document.set_page_setup(
800
+ margins_mm={
801
+ "left": _DEFAULT_PAGE_MARGIN_MM,
802
+ "right": _DEFAULT_PAGE_MARGIN_MM,
803
+ "top": _DEFAULT_PAGE_MARGIN_MM,
804
+ "bottom": _DEFAULT_PAGE_MARGIN_MM,
805
+ }
806
+ )
767
807
  tokens = style_preset.ensure_tokens(document)
768
808
  builder_document = _lower_plan_to_builder_document(normalized)
769
809
 
770
810
  if normalized.title:
771
- document.add_paragraph(
811
+ paragraph = document.add_paragraph(
772
812
  normalized.title,
773
813
  char_pr_id_ref=tokens["title"],
774
814
  inherit_style=False,
775
815
  )
816
+ _format_para(
817
+ document,
818
+ paragraph,
819
+ alignment="center",
820
+ line_spacing=130,
821
+ after_pt=2,
822
+ bottom_border=style_preset.title_rule,
823
+ border_color=style_preset.rule_color,
824
+ )
776
825
  if normalized.subtitle:
777
- document.add_paragraph(
826
+ paragraph = document.add_paragraph(
778
827
  normalized.subtitle,
779
828
  char_pr_id_ref=tokens["subtitle"],
780
829
  inherit_style=False,
781
830
  )
831
+ _format_para(document, paragraph, line_spacing=130, after_pt=10)
782
832
 
783
833
  if normalized.metadata:
784
- document.add_paragraph(
834
+ paragraph = document.add_paragraph(
785
835
  "문서 정보",
786
836
  char_pr_id_ref=tokens["heading"],
787
837
  inherit_style=False,
788
838
  )
839
+ _format_para(
840
+ document,
841
+ paragraph,
842
+ line_spacing=150,
843
+ before_pt=14,
844
+ after_pt=4,
845
+ bottom_border=style_preset.heading_rule,
846
+ border_color=style_preset.rule_color,
847
+ )
789
848
  _add_key_value_table(document, normalized.metadata, tokens)
790
849
 
791
850
  for block in builder_document.sections[0].children:
792
- _render_block(document, block, tokens)
851
+ _render_block(document, block, tokens, style_preset=style_preset)
793
852
 
794
853
  return document
795
854
 
@@ -989,6 +1048,14 @@ def _validate_block(raw_block: Any, *, index: int) -> list[PlanValidationIssue]:
989
1048
  elif block_type == "paragraph":
990
1049
  issues.extend(_validate_paragraph_block(raw_block, path=path))
991
1050
  issues.extend(_computed_field_issues(raw_block.get("text"), path=f"{path}.text"))
1051
+ for run_index, run in enumerate(raw_block.get("runs") or []):
1052
+ if isinstance(run, Mapping):
1053
+ issues.extend(
1054
+ _computed_field_issues(
1055
+ run.get("text"),
1056
+ path=f"{path}.runs[{run_index}].text",
1057
+ )
1058
+ )
992
1059
  elif block_type == "bullets":
993
1060
  items = _string_list(raw_block.get("items") or raw_block.get("bullets"))
994
1061
  if not items:
@@ -1075,7 +1142,37 @@ def _validate_heading_block(raw_block: Mapping[str, Any], *, path: str) -> list[
1075
1142
 
1076
1143
 
1077
1144
  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",))
1145
+ issues: list[PlanValidationIssue] = []
1146
+ text = str(raw_block.get("text") or "").strip()
1147
+ runs = raw_block.get("runs")
1148
+ has_rich_runs = False
1149
+ if runs is not None:
1150
+ if not isinstance(runs, list):
1151
+ issues.append(
1152
+ _plan_issue(
1153
+ "invalid_runs",
1154
+ f"{path}.runs",
1155
+ f"{path}.runs must be a list of run objects",
1156
+ suggestion="Use runs=[{'text': '...', 'bold': true, 'color': '#1F3864'}].",
1157
+ )
1158
+ )
1159
+ else:
1160
+ for run_index, run in enumerate(runs):
1161
+ run_path = f"{path}.runs[{run_index}]"
1162
+ if not isinstance(run, Mapping):
1163
+ issues.append(
1164
+ _plan_issue(
1165
+ "invalid_run",
1166
+ run_path,
1167
+ f"{run_path} must be a mapping",
1168
+ suggestion="Use a run object with text and optional bold/color fields.",
1169
+ )
1170
+ )
1171
+ continue
1172
+ if str(run.get("text") or "").strip():
1173
+ has_rich_runs = True
1174
+ if not text and not has_rich_runs:
1175
+ issues.extend(_validate_required_text_fields(raw_block, path=path, fields=("text",)))
1079
1176
  style = str(raw_block.get("style") or "body").strip() or "body"
1080
1177
  if style not in _SUPPORTED_STYLE_TOKENS:
1081
1178
  issues.append(
@@ -1314,12 +1411,21 @@ def _normalize_block(raw_block: Any, *, index: int) -> DocumentBlock:
1314
1411
  return DocumentBlock("heading", {"level": level, "text": replace_computed_fields(text)})
1315
1412
 
1316
1413
  if block_type == "paragraph":
1414
+ runs = _normalize_paragraph_runs(raw_block.get("runs"), index=index)
1415
+ text = (
1416
+ replace_computed_fields(str(raw_block.get("text") or ""))
1417
+ if runs
1418
+ else replace_computed_fields(_required_text(raw_block, "text", index))
1419
+ )
1420
+ data: dict[str, Any] = {
1421
+ "text": text,
1422
+ "style": str(raw_block.get("style") or "body").strip() or "body",
1423
+ }
1424
+ if runs:
1425
+ data["runs"] = runs
1317
1426
  return DocumentBlock(
1318
1427
  "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
- },
1428
+ data,
1323
1429
  )
1324
1430
 
1325
1431
  if block_type == "bullets":
@@ -1364,6 +1470,29 @@ def _normalize_block(raw_block: Any, *, index: int) -> DocumentBlock:
1364
1470
  return DocumentBlock("page_break", {})
1365
1471
 
1366
1472
 
1473
+ def _normalize_paragraph_runs(value: Any, *, index: int) -> list[dict[str, Any]]:
1474
+ if value is None:
1475
+ return []
1476
+ if not isinstance(value, list):
1477
+ raise ValueError(f"blocks[{index}].runs must be a list")
1478
+ runs: list[dict[str, Any]] = []
1479
+ for run_index, raw_run in enumerate(value):
1480
+ if not isinstance(raw_run, Mapping):
1481
+ raise ValueError(f"blocks[{index}].runs[{run_index}] must be a mapping")
1482
+ text = replace_computed_fields(str(raw_run.get("text") or ""))
1483
+ if not text:
1484
+ continue
1485
+ run: dict[str, Any] = {"text": text}
1486
+ if "bold" in raw_run:
1487
+ run["bold"] = bool(raw_run.get("bold"))
1488
+ if "color" in raw_run:
1489
+ color = _optional_str(raw_run.get("color"))
1490
+ if color is not None:
1491
+ run["color"] = color
1492
+ runs.append(run)
1493
+ return runs
1494
+
1495
+
1367
1496
  def _normalize_v2_builder_document(plan: Mapping[str, Any]) -> BuilderDocument:
1368
1497
  metadata = plan.get("metadata") or {}
1369
1498
  builder_metadata = None
@@ -1491,9 +1620,12 @@ def _normalize_v2_block(raw_block: Any, *, path: str) -> Any:
1491
1620
  text=replace_computed_fields(str(raw_block.get("text") or "")),
1492
1621
  )
1493
1622
  if block_type == "paragraph":
1623
+ raw_children = raw_block.get("children")
1624
+ if raw_children is None:
1625
+ raw_children = raw_block.get("runs")
1494
1626
  children = tuple(
1495
1627
  child
1496
- for child in (_normalize_v2_paragraph_child(child) for child in raw_block.get("children") or [])
1628
+ for child in (_normalize_v2_paragraph_child(child) for child in raw_children or [])
1497
1629
  if isinstance(child, BuilderRun)
1498
1630
  )
1499
1631
  return BuilderParagraph(
@@ -1639,6 +1771,15 @@ def _block_to_builder_nodes(block: DocumentBlock) -> tuple[Any, ...]:
1639
1771
  ),
1640
1772
  )
1641
1773
  if block.type == "paragraph":
1774
+ runs = block.data.get("runs") or []
1775
+ if runs:
1776
+ return (
1777
+ BuilderParagraph(
1778
+ text=str(block.data.get("text") or ""),
1779
+ children=tuple(_builder_run_from_plan(run) for run in runs),
1780
+ style=str(block.data.get("style") or "body"),
1781
+ ),
1782
+ )
1642
1783
  return (
1643
1784
  BuilderParagraph(
1644
1785
  text=str(block.data["text"]),
@@ -1675,6 +1816,14 @@ def _block_to_builder_nodes(block: DocumentBlock) -> tuple[Any, ...]:
1675
1816
  raise ValueError(f"unsupported block type: {block.type!r}")
1676
1817
 
1677
1818
 
1819
+ def _builder_run_from_plan(run: Mapping[str, Any]) -> BuilderRun:
1820
+ return BuilderRun(
1821
+ text=str(run.get("text") or ""),
1822
+ bold=bool(run.get("bold", False)),
1823
+ color=_optional_str(run.get("color")),
1824
+ )
1825
+
1826
+
1678
1827
  def _plan_table_column_widths(columns: list[dict[str, Any]]) -> list[int]:
1679
1828
  total = sum(max(int(column.get("widthWeight", 1)), 1) for column in columns)
1680
1829
  if total <= 0:
@@ -1740,34 +1889,134 @@ def _normalize_table_cell_value(value: Any) -> str:
1740
1889
  return normalize_cell_text(value)
1741
1890
 
1742
1891
 
1892
+ def _format_para(
1893
+ document: HwpxDocument,
1894
+ paragraph: Any,
1895
+ *,
1896
+ alignment: str | None = None,
1897
+ line_spacing: int | None = None,
1898
+ before_pt: float | None = None,
1899
+ after_pt: float | None = None,
1900
+ bottom_border: bool = False,
1901
+ border_color: str = "#BFBFBF",
1902
+ ) -> None:
1903
+ """Apply breathing-room paragraph spacing to a freshly added paragraph.
1904
+
1905
+ Uses the public ``set_paragraph_format`` so unit conversion and paraPr
1906
+ deduplication are handled by the engine. Failures are non-fatal: spacing is
1907
+ a presentation nicety, never a reason to abort document generation.
1908
+ """
1909
+
1910
+ kwargs: dict[str, Any] = {}
1911
+ if alignment is not None:
1912
+ kwargs["alignment"] = alignment
1913
+ if line_spacing is not None:
1914
+ kwargs["line_spacing_percent"] = line_spacing
1915
+ if before_pt is not None:
1916
+ kwargs["spacing_before_pt"] = before_pt
1917
+ if after_pt is not None:
1918
+ kwargs["spacing_after_pt"] = after_pt
1919
+ if bottom_border:
1920
+ kwargs["bottom_border"] = True
1921
+ kwargs["border_color"] = border_color
1922
+ if not kwargs:
1923
+ return
1924
+ try:
1925
+ index = document.paragraphs.index(paragraph)
1926
+ document.set_paragraph_format(paragraph_index=index, **kwargs)
1927
+ except (ValueError, KeyError):
1928
+ return
1929
+
1930
+
1931
+ def _add_rich_runs(
1932
+ document: HwpxDocument,
1933
+ paragraph: Any,
1934
+ runs: Any,
1935
+ *,
1936
+ base_char_pr_id: str,
1937
+ ) -> None:
1938
+ for run in runs:
1939
+ if not isinstance(run, BuilderRun):
1940
+ raise ValueError(f"unsupported paragraph child: {type(run).__name__}")
1941
+ char_pr_id = document.ensure_run_style(
1942
+ bold=bool(run.bold),
1943
+ italic=bool(run.italic),
1944
+ underline=bool(run.underline),
1945
+ color=run.color,
1946
+ font=run.font,
1947
+ size=run.size,
1948
+ highlight=run.highlight,
1949
+ strike=run.strike,
1950
+ base_char_pr_id=base_char_pr_id,
1951
+ )
1952
+ paragraph.add_run(str(run.text or ""), char_pr_id_ref=char_pr_id)
1953
+
1954
+
1743
1955
  def _render_block(
1744
1956
  document: HwpxDocument,
1745
1957
  block: Any,
1746
1958
  tokens: Mapping[str, str],
1959
+ *,
1960
+ style_preset: DocumentStylePreset,
1747
1961
  ) -> None:
1748
1962
  if isinstance(block, BuilderHeading):
1749
- document.add_paragraph(
1963
+ paragraph = document.add_paragraph(
1750
1964
  block.text,
1751
1965
  char_pr_id_ref=tokens["heading"],
1752
1966
  inherit_style=False,
1753
1967
  **_outline_style_refs(document, block.level),
1754
1968
  )
1969
+ _format_para(
1970
+ document,
1971
+ paragraph,
1972
+ line_spacing=150,
1973
+ before_pt=14,
1974
+ after_pt=4,
1975
+ bottom_border=style_preset.heading_rule,
1976
+ border_color=style_preset.rule_color,
1977
+ )
1755
1978
  return
1756
1979
  if isinstance(block, BuilderParagraph):
1757
1980
  style = str(block.style or "body")
1758
- document.add_paragraph(
1981
+ if block.children:
1982
+ paragraph = document.add_paragraph("", include_run=False, inherit_style=False)
1983
+ _add_rich_runs(
1984
+ document,
1985
+ paragraph,
1986
+ block.children,
1987
+ base_char_pr_id=tokens.get(style, tokens["body"]),
1988
+ )
1989
+ _format_para(
1990
+ document,
1991
+ paragraph,
1992
+ line_spacing=165,
1993
+ after_pt=4,
1994
+ bottom_border=style == "heading" and style_preset.heading_rule,
1995
+ border_color=style_preset.rule_color,
1996
+ )
1997
+ return
1998
+ paragraph = document.add_paragraph(
1759
1999
  block.text,
1760
2000
  char_pr_id_ref=tokens.get(style, tokens["body"]),
1761
2001
  inherit_style=False,
1762
2002
  )
2003
+ _format_para(
2004
+ document,
2005
+ paragraph,
2006
+ line_spacing=165,
2007
+ after_pt=4,
2008
+ bottom_border=style == "heading" and style_preset.heading_rule,
2009
+ border_color=style_preset.rule_color,
2010
+ )
1763
2011
  return
1764
2012
  if isinstance(block, BuilderBullet):
1765
2013
  for item in block.items:
1766
- document.add_paragraph(
2014
+ paragraph = document.add_paragraph(
1767
2015
  f"• {item}",
1768
2016
  char_pr_id_ref=tokens["bullet"],
1769
2017
  inherit_style=False,
1770
2018
  )
2019
+ _format_para(document, paragraph, line_spacing=150, after_pt=2)
1771
2020
  return
1772
2021
  if isinstance(block, BuilderTable):
1773
2022
  _add_builder_table(document, block, tokens)
@@ -1848,6 +2097,7 @@ def _add_plan_table(
1848
2097
  str(row.get(column["key"], "")),
1849
2098
  char_pr_id_ref=tokens["table_cell"],
1850
2099
  )
2100
+ _style_plan_table(document, table, header_fill=_TABLE_HEADER_FILL)
1851
2101
 
1852
2102
 
1853
2103
  def _add_builder_table(
@@ -1888,6 +2138,12 @@ def _add_builder_table(
1888
2138
  str(value),
1889
2139
  char_pr_id_ref=tokens["table_cell"],
1890
2140
  )
2141
+ _style_plan_table(
2142
+ document,
2143
+ table,
2144
+ header_fill=table_node.header_shading or _TABLE_HEADER_FILL,
2145
+ header_rows=1 if table_node.header else 0,
2146
+ )
1891
2147
 
1892
2148
 
1893
2149
  def _set_table_cell_text(
@@ -1904,6 +2160,47 @@ def _set_table_cell_text(
1904
2160
  paragraph.char_pr_id_ref = char_pr_id_ref
1905
2161
 
1906
2162
 
2163
+ def _style_plan_table(
2164
+ document: HwpxDocument,
2165
+ table: Any,
2166
+ *,
2167
+ header_fill: str,
2168
+ header_rows: int = 1,
2169
+ ) -> None:
2170
+ border_fill_id = document.ensure_border_fill(border_color=_TABLE_BORDER_COLOR)
2171
+ header_fill_id = document.ensure_border_fill(
2172
+ border_color=_TABLE_BORDER_COLOR,
2173
+ fill_color=header_fill,
2174
+ )
2175
+ table.element.set("borderFillIDRef", border_fill_id)
2176
+ center_para_pr_id: str | None = None
2177
+ if header_rows and document.oxml.headers:
2178
+ center_para_pr_id = document.oxml.headers[0].ensure_paragraph_format(alignment="center")
2179
+
2180
+ for row_index, row in enumerate(table.rows):
2181
+ for cell in row.cells:
2182
+ is_header = row_index < header_rows
2183
+ cell.element.set("borderFillIDRef", header_fill_id if is_header else border_fill_id)
2184
+ cell.element.set("hasMargin", "1")
2185
+ _set_cell_margin(cell)
2186
+ sublist = cell.element.find(f"{_HP}subList")
2187
+ if sublist is not None:
2188
+ sublist.set("vertAlign", "CENTER")
2189
+ if is_header and center_para_pr_id is not None:
2190
+ for paragraph in cell.paragraphs:
2191
+ paragraph.para_pr_id_ref = center_para_pr_id
2192
+ table.mark_dirty()
2193
+
2194
+
2195
+ def _set_cell_margin(cell: Any) -> None:
2196
+ margin = cell.element.find(f"{_HP}cellMargin")
2197
+ if margin is None:
2198
+ margin = cell.element.makeelement(f"{_HP}cellMargin", {})
2199
+ cell.element.append(margin)
2200
+ for side in ("left", "right", "top", "bottom"):
2201
+ margin.set(side, _TABLE_CELL_MARGIN)
2202
+
2203
+
1907
2204
  def _apply_column_widths(table: Any, columns: list[dict[str, Any]]) -> None:
1908
2205
  total = sum(max(int(column.get("widthWeight", 1)), 1) for column in columns)
1909
2206
  if total <= 0:
hwpx/builder/core.py CHANGED
@@ -769,7 +769,10 @@ class Document:
769
769
 
770
770
  def save_to_path(self, path: str | PathLike[str]) -> BuilderSaveReport:
771
771
  document = self.lower()
772
- document.save_to_path(path)
772
+ # Funnel the write through the single SavePipeline and keep its uniform
773
+ # report (plan §2 Phase B). Transparent policy -> behaviour-identical to
774
+ # the prior ``document.save_to_path`` for a from-scratch (new) document.
775
+ visual_complete = document.save_report(path)
773
776
  package_report = validate_package(path)
774
777
  document_report = validate_document(path)
775
778
  editor_open_safety_report = validate_editor_open_safety(path)
@@ -799,5 +802,6 @@ class Document:
799
802
  visual_review_required=visual_review_required,
800
803
  feature_flags=feature_flags,
801
804
  editor_open_safety=editor_open_safety_report,
805
+ visual_complete=visual_complete,
802
806
  )
803
807
  return report
hwpx/builder/report.py CHANGED
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
5
5
  from os import PathLike
6
6
  from typing import Any
7
7
 
8
+ from hwpx.quality import VisualCompleteReport
8
9
  from hwpx.tools.id_integrity import IdIntegrityReport, check_id_integrity
9
10
  from hwpx.tools.package_validator import EditorOpenSafetyReport, PackageValidationReport
10
11
  from hwpx.tools.validator import ValidationReport
@@ -33,6 +34,10 @@ class BuilderSaveReport:
33
34
  feature_flags: dict[str, bool] = field(default_factory=dict)
34
35
  id_integrity: IdIntegrityReport | None = None
35
36
  editor_open_safety: EditorOpenSafetyReport | None = None
37
+ # The uniform Phase-B report from the SavePipeline the builder save funnelled
38
+ # through (plan §2 Phase B). Additive: ``None`` only if a caller builds a
39
+ # report by hand without going through ``Document.save_to_path``.
40
+ visual_complete: VisualCompleteReport | None = None
36
41
 
37
42
  def __post_init__(self) -> None:
38
43
  hard_gates = dict(self.hard_gates)
@@ -53,6 +58,9 @@ class BuilderSaveReport:
53
58
  "hard_gates": dict(self.hard_gates),
54
59
  "visual_review_required": self.visual_review_required,
55
60
  "feature_flags": dict(self.feature_flags),
61
+ "visual_complete": (
62
+ None if self.visual_complete is None else self.visual_complete.to_dict()
63
+ ),
56
64
  "editor_open_safety": (
57
65
  None
58
66
  if self.editor_open_safety is None
@@ -0,0 +1,54 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Conformance corpus + badges — make "docx-grade" measurable (plan §2 Phase G).
3
+
4
+ This package turns the VisualComplete quality bar into *numbers*: a corpus of
5
+ documents, a runner that scores each across four badge tiers (Open-Safe,
6
+ Semantic-Safe, Form-Safe, VisualComplete) against explicit thresholds, and a
7
+ golden baseline so a regression shows up as a dropped pass rate rather than a
8
+ vibe (the Phase-G acceptance).
9
+
10
+ The assurance tier is never blurred (plan §0.0): the structural run (any CI, no
11
+ Hancom) can claim Open/Semantic/Form but reports VisualComplete ``unverified``;
12
+ only the oracle run (a reachable Hancom backend) verifies VisualComplete.
13
+
14
+ Entry points::
15
+
16
+ from hwpx.conformance import ConformanceCorpus, run_conformance
17
+ report = run_conformance(ConformanceCorpus.bundled())
18
+ # or: hwpx-conformance run --tier structural
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from .badges import Badge, BadgeThresholds, evaluate_badge, evaluate_badges
23
+ from .corpus import (
24
+ BADGE_TIERS,
25
+ BadgeTier,
26
+ ConformanceCase,
27
+ ConformanceCorpus,
28
+ FormSlot,
29
+ )
30
+ from .report import (
31
+ CaseResult,
32
+ ConformanceReport,
33
+ TierVerdict,
34
+ diff_golden,
35
+ )
36
+ from .runner import evaluate_case, run_conformance
37
+
38
+ __all__ = [
39
+ "BADGE_TIERS",
40
+ "BadgeTier",
41
+ "FormSlot",
42
+ "ConformanceCase",
43
+ "ConformanceCorpus",
44
+ "TierVerdict",
45
+ "CaseResult",
46
+ "ConformanceReport",
47
+ "diff_golden",
48
+ "Badge",
49
+ "BadgeThresholds",
50
+ "evaluate_badge",
51
+ "evaluate_badges",
52
+ "run_conformance",
53
+ "evaluate_case",
54
+ ]