python-hwpx 2.11.0__tar.gz → 2.13.0__tar.gz

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 (211) hide show
  1. {python_hwpx-2.11.0/src/python_hwpx.egg-info → python_hwpx-2.13.0}/PKG-INFO +18 -1
  2. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/README.md +13 -0
  3. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/pyproject.toml +17 -1
  4. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/__init__.py +8 -0
  5. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/authoring.py +355 -24
  6. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/builder/core.py +25 -1
  7. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/builder/report.py +8 -0
  8. python_hwpx-2.13.0/src/hwpx/conformance/__init__.py +54 -0
  9. python_hwpx-2.13.0/src/hwpx/conformance/badges.py +198 -0
  10. python_hwpx-2.13.0/src/hwpx/conformance/corpus/corpus.json +53 -0
  11. python_hwpx-2.13.0/src/hwpx/conformance/corpus/meeting_summary.hwpx +0 -0
  12. python_hwpx-2.13.0/src/hwpx/conformance/corpus/notice.hwpx +0 -0
  13. python_hwpx-2.13.0/src/hwpx/conformance/corpus/report_table.hwpx +0 -0
  14. python_hwpx-2.13.0/src/hwpx/conformance/corpus.py +260 -0
  15. python_hwpx-2.13.0/src/hwpx/conformance/report.py +223 -0
  16. python_hwpx-2.13.0/src/hwpx/conformance/runner.py +395 -0
  17. python_hwpx-2.13.0/src/hwpx/design/__init__.py +30 -0
  18. python_hwpx-2.13.0/src/hwpx/design/_support.py +144 -0
  19. python_hwpx-2.13.0/src/hwpx/design/composer.py +282 -0
  20. python_hwpx-2.13.0/src/hwpx/design/harvest.py +305 -0
  21. python_hwpx-2.13.0/src/hwpx/design/plan.py +69 -0
  22. python_hwpx-2.13.0/src/hwpx/design/profile.py +88 -0
  23. python_hwpx-2.13.0/src/hwpx/design/profiles/application_form/fragments/body.xml +1 -0
  24. python_hwpx-2.13.0/src/hwpx/design/profiles/application_form/fragments/heading.xml +1 -0
  25. python_hwpx-2.13.0/src/hwpx/design/profiles/application_form/fragments/info_table.xml +1 -0
  26. python_hwpx-2.13.0/src/hwpx/design/profiles/application_form/fragments/title.xml +1 -0
  27. python_hwpx-2.13.0/src/hwpx/design/profiles/application_form/profile.json +25 -0
  28. python_hwpx-2.13.0/src/hwpx/design/profiles/application_form/template.hwpx +0 -0
  29. python_hwpx-2.13.0/src/hwpx/design/profiles/official_notice/fragments/body.xml +1 -0
  30. python_hwpx-2.13.0/src/hwpx/design/profiles/official_notice/fragments/heading.xml +1 -0
  31. python_hwpx-2.13.0/src/hwpx/design/profiles/official_notice/fragments/info_table.xml +1 -0
  32. python_hwpx-2.13.0/src/hwpx/design/profiles/official_notice/fragments/title.xml +1 -0
  33. python_hwpx-2.13.0/src/hwpx/design/profiles/official_notice/profile.json +25 -0
  34. python_hwpx-2.13.0/src/hwpx/design/profiles/official_notice/template.hwpx +0 -0
  35. python_hwpx-2.13.0/src/hwpx/design/profiles/report/fragments/body.xml +1 -0
  36. python_hwpx-2.13.0/src/hwpx/design/profiles/report/fragments/heading.xml +1 -0
  37. python_hwpx-2.13.0/src/hwpx/design/profiles/report/fragments/info_table.xml +1 -0
  38. python_hwpx-2.13.0/src/hwpx/design/profiles/report/fragments/title.xml +1 -0
  39. python_hwpx-2.13.0/src/hwpx/design/profiles/report/profile.json +25 -0
  40. python_hwpx-2.13.0/src/hwpx/design/profiles/report/template.hwpx +0 -0
  41. python_hwpx-2.13.0/src/hwpx/design/validator.py +107 -0
  42. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/document.py +244 -81
  43. python_hwpx-2.13.0/src/hwpx/form_fit/__init__.py +51 -0
  44. python_hwpx-2.13.0/src/hwpx/form_fit/apply.py +96 -0
  45. python_hwpx-2.13.0/src/hwpx/form_fit/engine.py +294 -0
  46. python_hwpx-2.13.0/src/hwpx/form_fit/measure.py +369 -0
  47. python_hwpx-2.13.0/src/hwpx/form_fit/policy.py +84 -0
  48. python_hwpx-2.13.0/src/hwpx/form_fit/report.py +93 -0
  49. python_hwpx-2.13.0/src/hwpx/layout/__init__.py +36 -0
  50. python_hwpx-2.13.0/src/hwpx/layout/lint.py +384 -0
  51. python_hwpx-2.13.0/src/hwpx/layout/report.py +121 -0
  52. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/_document_impl.py +242 -1
  53. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/patch.py +76 -13
  54. python_hwpx-2.13.0/src/hwpx/quality/__init__.py +45 -0
  55. python_hwpx-2.13.0/src/hwpx/quality/ledger.py +111 -0
  56. python_hwpx-2.13.0/src/hwpx/quality/policy.py +95 -0
  57. python_hwpx-2.13.0/src/hwpx/quality/report.py +228 -0
  58. python_hwpx-2.13.0/src/hwpx/quality/save_pipeline.py +556 -0
  59. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/template_formfit.py +5 -1
  60. python_hwpx-2.13.0/src/hwpx/visual/__init__.py +78 -0
  61. python_hwpx-2.13.0/src/hwpx/visual/_render_hwpx.ps1 +72 -0
  62. python_hwpx-2.13.0/src/hwpx/visual/_render_hwpx_mac.applescript +222 -0
  63. python_hwpx-2.13.0/src/hwpx/visual/detectors.py +152 -0
  64. python_hwpx-2.13.0/src/hwpx/visual/diff.py +153 -0
  65. python_hwpx-2.13.0/src/hwpx/visual/masks.py +51 -0
  66. python_hwpx-2.13.0/src/hwpx/visual/oracle.py +505 -0
  67. python_hwpx-2.13.0/src/hwpx/visual/report.py +47 -0
  68. {python_hwpx-2.11.0 → python_hwpx-2.13.0/src/python_hwpx.egg-info}/PKG-INFO +18 -1
  69. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/python_hwpx.egg-info/SOURCES.txt +65 -1
  70. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/python_hwpx.egg-info/entry_points.txt +1 -0
  71. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/python_hwpx.egg-info/requires.txt +5 -0
  72. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_builder_core.py +9 -0
  73. python_hwpx-2.13.0/tests/test_conformance.py +426 -0
  74. python_hwpx-2.13.0/tests/test_design_builder.py +297 -0
  75. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_document_plan.py +157 -0
  76. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_document_save_api.py +3 -1
  77. python_hwpx-2.13.0/tests/test_form_fit.py +218 -0
  78. python_hwpx-2.13.0/tests/test_form_fit_integration.py +157 -0
  79. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_kordoc_absorption.py +61 -0
  80. python_hwpx-2.13.0/tests/test_layout_lint.py +455 -0
  81. python_hwpx-2.13.0/tests/test_save_pipeline.py +284 -0
  82. python_hwpx-2.13.0/tests/test_save_pipeline_no_bypass.py +134 -0
  83. python_hwpx-2.13.0/tests/test_visual_oracle.py +300 -0
  84. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/LICENSE +0 -0
  85. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/NOTICE +0 -0
  86. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/setup.cfg +0 -0
  87. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/builder/__init__.py +0 -0
  88. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/data/Skeleton.hwpx +0 -0
  89. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/form_fill.py +0 -0
  90. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/opc/package.py +0 -0
  91. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/opc/relationships.py +0 -0
  92. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/opc/security.py +0 -0
  93. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/opc/xml_utils.py +0 -0
  94. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/__init__.py +0 -0
  95. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/body.py +0 -0
  96. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/common.py +0 -0
  97. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/document.py +0 -0
  98. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/header.py +0 -0
  99. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/header_part.py +0 -0
  100. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/memo.py +0 -0
  101. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/namespaces.py +0 -0
  102. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/numbering.py +0 -0
  103. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/objects.py +0 -0
  104. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/paragraph.py +0 -0
  105. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/parser.py +0 -0
  106. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/run.py +0 -0
  107. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/schema.py +0 -0
  108. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/section.py +0 -0
  109. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/simple_parts.py +0 -0
  110. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/table.py +0 -0
  111. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/oxml/utils.py +0 -0
  112. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/package.py +0 -0
  113. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/presets/__init__.py +0 -0
  114. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/presets/proposal.py +0 -0
  115. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/py.typed +0 -0
  116. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/templates.py +0 -0
  117. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/__init__.py +0 -0
  118. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/_schemas/header.xsd +0 -0
  119. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/_schemas/section.xsd +0 -0
  120. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/advanced_generators.py +0 -0
  121. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/archive_cli.py +0 -0
  122. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/doc_diff.py +0 -0
  123. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/exporter.py +0 -0
  124. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/fuzz/__init__.py +0 -0
  125. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/fuzz/__main__.py +0 -0
  126. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/fuzz/catalog.py +0 -0
  127. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/fuzz/generator.py +0 -0
  128. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/fuzz/minimize.py +0 -0
  129. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/fuzz/runner.py +0 -0
  130. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/generic_inventory.py +0 -0
  131. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/id_integrity.py +0 -0
  132. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/layout_preview.py +0 -0
  133. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/mail_merge.py +0 -0
  134. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/markdown_export.py +0 -0
  135. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/object_finder.py +0 -0
  136. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/official_lint.py +0 -0
  137. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/package_validator.py +0 -0
  138. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/page_guard.py +0 -0
  139. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/recover.py +0 -0
  140. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/repair.py +0 -0
  141. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/report_parser.py +0 -0
  142. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/report_utils.py +0 -0
  143. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/roundtrip_diff.py +0 -0
  144. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/style_profile.py +0 -0
  145. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/table_cleanup.py +0 -0
  146. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/table_compute.py +0 -0
  147. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/table_navigation.py +0 -0
  148. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/template_analyzer.py +0 -0
  149. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/text_extract_cli.py +0 -0
  150. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/text_extractor.py +0 -0
  151. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/hwpx/tools/validator.py +0 -0
  152. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
  153. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/src/python_hwpx.egg-info/top_level.txt +0 -0
  154. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_advanced_generators.py +0 -0
  155. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_builder_plan_v2.py +0 -0
  156. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_builder_vertical_slice.py +0 -0
  157. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_coverage_promotion.py +0 -0
  158. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_coverage_targets.py +0 -0
  159. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_deviations_registry.py +0 -0
  160. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_doc_diff.py +0 -0
  161. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_document_context_manager.py +0 -0
  162. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_document_formatting.py +0 -0
  163. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_document_plan_computed_fields.py +0 -0
  164. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_existing_document_format_editing.py +0 -0
  165. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_form_fields.py +0 -0
  166. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_form_fill_split_run.py +0 -0
  167. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_fuzz_loop.py +0 -0
  168. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_fuzz_regressions.py +0 -0
  169. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_gap_closure_tools.py +0 -0
  170. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_government_report_preset.py +0 -0
  171. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_government_table_profile.py +0 -0
  172. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_hp_tab_support.py +0 -0
  173. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_hwpxlib_corpus_read.py +0 -0
  174. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_id_generator_range.py +0 -0
  175. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_id_integrity.py +0 -0
  176. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_image_object_workflow.py +0 -0
  177. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_inline_models.py +0 -0
  178. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_integration_hwpx_compatibility.py +0 -0
  179. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_integration_roundtrip.py +0 -0
  180. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_layout_preview.py +0 -0
  181. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_mail_merge_table_compute.py +0 -0
  182. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_markdown_export.py +0 -0
  183. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_memo_and_style_editing.py +0 -0
  184. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_namespace_handling.py +0 -0
  185. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_new_features.py +0 -0
  186. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_official_document_style.py +0 -0
  187. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_opc_package.py +0 -0
  188. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_open_safety_corpus.py +0 -0
  189. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_oxml_parsing.py +0 -0
  190. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_packaging_license_metadata.py +0 -0
  191. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_packaging_py_typed.py +0 -0
  192. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_paragraph_section_management.py +0 -0
  193. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_proposal_preset.py +0 -0
  194. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_recover_broken_zip.py +0 -0
  195. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_repair_repack.py +0 -0
  196. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_report_parser.py +0 -0
  197. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_report_utils.py +0 -0
  198. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_repr_snapshots.py +0 -0
  199. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_roundtrip_fidelity.py +0 -0
  200. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_section_headers.py +0 -0
  201. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_skeleton_template_ids.py +0 -0
  202. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_split_merged_cell.py +0 -0
  203. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_style_profile.py +0 -0
  204. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_table_cleanup.py +0 -0
  205. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_table_navigation.py +0 -0
  206. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_tables_default_border.py +0 -0
  207. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_template_analyzer_enrichment.py +0 -0
  208. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_template_formfit.py +0 -0
  209. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_text_extractor_annotations.py +0 -0
  210. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_validation_severity.py +0 -0
  211. {python_hwpx-2.11.0 → python_hwpx-2.13.0}/tests/test_version_metadata.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 2.11.0
3
+ Version: 2.13.0
4
4
  Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리
5
5
  Author: python-hwpx Maintainers
6
6
  License-Expression: Apache-2.0
@@ -21,6 +21,10 @@ Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  License-File: NOTICE
23
23
  Requires-Dist: lxml<6,>=4.9
24
+ Provides-Extra: visual
25
+ Requires-Dist: pymupdf>=1.24; extra == "visual"
26
+ Requires-Dist: pillow>=10.0; extra == "visual"
27
+ Requires-Dist: numpy>=1.26; extra == "visual"
24
28
  Provides-Extra: dev
25
29
  Requires-Dist: build>=1.0; extra == "dev"
26
30
  Requires-Dist: twine>=4.0; extra == "dev"
@@ -261,6 +265,19 @@ HWPX 파일은 **ZIP + XML** 구조이므로, 한/글 프로그램 없이 Python
261
265
  | 🧰 **작업 도구** | unpack/pack/분석/비교 | pack-ready 작업 디렉터리 추출과 재구성 점검 |
262
266
  | 🏗️ **저수준 XML** | 데이터클래스 매핑 | OWPML 스키마 ↔ Python 객체 직접 조작 |
263
267
  | 🔄 **네임스페이스 호환** | 자동 정규화 | HWPML 2016 → 2011 자동 변환 |
268
+ | 🏗️ **빌더** | 조립형 생성 | `hwpx.builder` — Section/Heading/Table/Image/Header 조립, 하드게이트 저장 리포트 |
269
+ | ✅ **편집기 오픈 안전** | `validate_editor_open_safety` | 저장/팩/리페어/빌더 출력 게이트, `openSafety` 증거 반환 |
270
+ | 🧪 **퍼징 수렴 루프** | `hwpx.tools.fuzz` | 시드 결정적 시나리오 생성 · 3중 오라클 러너 · 회귀 fixture 박제 |
271
+ | 🖥️ **레이아웃 프리뷰** | `hwpx.tools.layout_preview` | 페이지 박스·표·여백 근사 HTML/PNG (에이전트 자기검증용) |
272
+ | 🧷 **바이트 보존 패치** | `hwpx.patch` | section XML 바이트 splice — 미수정 영역 바이트 보존 |
273
+ | 📐 **기존 문서 서식 편집** | 문단·페이지 | 정렬·줄간격·들여쓰기·문단 간격, 용지·여백·방향, 머리말/쪽번호, 불릿/번호 |
274
+ | 🖊️ **누름틀** | 양식 필드 | 클릭히어 필드 조회·서식 보존 채움 |
275
+ | 🏛️ **공문서 도구** | `official_lint` · 결재란 | 항목기호 위계·"끝." 표시·붙임·날짜 표기 lint, 결재란 프리셋 |
276
+ | 📷 **고급 생성기** | `advanced_generators` | 사진대지(image_grid)·회의 명패·표 기반 조직도 |
277
+ | 🆚 **신구대조** | `doc_diff` | 문단 LCS diff·신구대조표 생성·참조 정합 lint |
278
+ | 📨 **메일머지·표 계산** | `mail_merge` | 템플릿+데이터 N부 대량 생성, 표 합계·평균 |
279
+ | 🪄 **서식 이식** | `style_profile` | 참조 문서 프로파일 추출·적용, 템플릿 레지스트리 |
280
+ | 🛡️ **입력 강건화** | `opc.security` | XML entity 폭탄·ZIP 압축 폭탄 가드 |
264
281
 
265
282
  ## 기능 상세
266
283
 
@@ -225,6 +225,19 @@ HWPX 파일은 **ZIP + XML** 구조이므로, 한/글 프로그램 없이 Python
225
225
  | 🧰 **작업 도구** | unpack/pack/분석/비교 | pack-ready 작업 디렉터리 추출과 재구성 점검 |
226
226
  | 🏗️ **저수준 XML** | 데이터클래스 매핑 | OWPML 스키마 ↔ Python 객체 직접 조작 |
227
227
  | 🔄 **네임스페이스 호환** | 자동 정규화 | HWPML 2016 → 2011 자동 변환 |
228
+ | 🏗️ **빌더** | 조립형 생성 | `hwpx.builder` — Section/Heading/Table/Image/Header 조립, 하드게이트 저장 리포트 |
229
+ | ✅ **편집기 오픈 안전** | `validate_editor_open_safety` | 저장/팩/리페어/빌더 출력 게이트, `openSafety` 증거 반환 |
230
+ | 🧪 **퍼징 수렴 루프** | `hwpx.tools.fuzz` | 시드 결정적 시나리오 생성 · 3중 오라클 러너 · 회귀 fixture 박제 |
231
+ | 🖥️ **레이아웃 프리뷰** | `hwpx.tools.layout_preview` | 페이지 박스·표·여백 근사 HTML/PNG (에이전트 자기검증용) |
232
+ | 🧷 **바이트 보존 패치** | `hwpx.patch` | section XML 바이트 splice — 미수정 영역 바이트 보존 |
233
+ | 📐 **기존 문서 서식 편집** | 문단·페이지 | 정렬·줄간격·들여쓰기·문단 간격, 용지·여백·방향, 머리말/쪽번호, 불릿/번호 |
234
+ | 🖊️ **누름틀** | 양식 필드 | 클릭히어 필드 조회·서식 보존 채움 |
235
+ | 🏛️ **공문서 도구** | `official_lint` · 결재란 | 항목기호 위계·"끝." 표시·붙임·날짜 표기 lint, 결재란 프리셋 |
236
+ | 📷 **고급 생성기** | `advanced_generators` | 사진대지(image_grid)·회의 명패·표 기반 조직도 |
237
+ | 🆚 **신구대조** | `doc_diff` | 문단 LCS diff·신구대조표 생성·참조 정합 lint |
238
+ | 📨 **메일머지·표 계산** | `mail_merge` | 템플릿+데이터 N부 대량 생성, 표 합계·평균 |
239
+ | 🪄 **서식 이식** | `style_profile` | 참조 문서 프로파일 추출·적용, 템플릿 레지스트리 |
240
+ | 🛡️ **입력 강건화** | `opc.security` | XML entity 폭탄·ZIP 압축 폭탄 가드 |
228
241
 
229
242
  ## 기능 상세
230
243
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-hwpx"
7
- version = "2.11.0"
7
+ version = "2.13.0"
8
8
  description = "한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = "Apache-2.0"
@@ -29,6 +29,11 @@ dependencies = [
29
29
  ]
30
30
 
31
31
  [project.optional-dependencies]
32
+ visual = [
33
+ "pymupdf>=1.24",
34
+ "pillow>=10.0",
35
+ "numpy>=1.26",
36
+ ]
32
37
  dev = [
33
38
  "build>=1.0",
34
39
  "twine>=4.0",
@@ -58,6 +63,7 @@ hwpx-page-guard = "hwpx.tools.page_guard:main"
58
63
  hwpx-analyze-template = "hwpx.tools.template_analyzer:main"
59
64
  hwpx-text-extract = "hwpx.tools.text_extract_cli:main"
60
65
  hwpx-repair = "hwpx.tools.repair:main"
66
+ hwpx-conformance = "hwpx.conformance.runner:main"
61
67
 
62
68
  [tool.setuptools]
63
69
  package-dir = { "" = "src" }
@@ -71,6 +77,16 @@ include = ["hwpx*"]
71
77
  "hwpx" = ["py.typed"]
72
78
  "hwpx.tools" = ["_schemas/*.xsd"]
73
79
  "hwpx.data" = ["Skeleton.hwpx"]
80
+ "hwpx.visual" = ["_render_hwpx.ps1", "_render_hwpx_mac.applescript"]
81
+ "hwpx.design" = [
82
+ "profiles/*/profile.json",
83
+ "profiles/*/template.hwpx",
84
+ "profiles/*/fragments/*.xml",
85
+ ]
86
+ "hwpx.conformance" = [
87
+ "corpus/corpus.json",
88
+ "corpus/*.hwpx",
89
+ ]
74
90
 
75
91
  [tool.pytest.ini_options]
76
92
  pythonpath = ["src"]
@@ -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",
@@ -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,28 +169,85 @@ 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
176
+ title_size: int = 20
177
+ subtitle_size: int = 12
178
+ heading_size: int = 14
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"
171
189
 
172
190
  def ensure_tokens(self, document: HwpxDocument) -> dict[str, str]:
173
191
  """Create/reuse run styles and return semantic token IDs."""
174
192
 
175
193
  return {
176
- "title": document.ensure_run_style(bold=self.title_bold),
177
- "subtitle": document.ensure_run_style(italic=self.subtitle_italic),
194
+ "title": document.ensure_run_style(
195
+ bold=self.title_bold,
196
+ size=self.title_size,
197
+ font=self.font,
198
+ color=self.title_color,
199
+ ),
200
+ "subtitle": document.ensure_run_style(
201
+ italic=self.subtitle_italic,
202
+ size=self.subtitle_size,
203
+ font=self.font,
204
+ color=self.subtitle_color,
205
+ ),
178
206
  "heading": document.ensure_run_style(
179
207
  bold=self.heading_bold,
180
208
  underline=self.heading_underline,
209
+ size=self.heading_size,
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,
181
228
  ),
182
- "body": document.ensure_run_style(),
183
- "bullet": document.ensure_run_style(),
184
- "table_header": document.ensure_run_style(bold=self.table_header_bold),
185
- "table_cell": document.ensure_run_style(),
186
229
  }
187
230
 
188
231
 
232
+ def _outline_style_refs(document: HwpxDocument, level: int) -> dict[str, str | int]:
233
+ """Return paragraph style refs for a HWP outline heading level, if available."""
234
+
235
+ safe_level = min(10, max(1, int(level)))
236
+ for style in document.styles.values():
237
+ name = str(style.name or "")
238
+ eng_name = str(style.eng_name or "")
239
+ if name == f"개요 {safe_level}" or eng_name == f"Outline {safe_level}":
240
+ refs: dict[str, str | int] = {}
241
+ style_id = style.raw_id if style.raw_id is not None else style.id
242
+ if style_id is None:
243
+ continue
244
+ refs["style_id_ref"] = style_id
245
+ if style.para_pr_id_ref is not None:
246
+ refs["para_pr_id_ref"] = int(style.para_pr_id_ref)
247
+ return refs
248
+ return {}
249
+
250
+
189
251
  def _plan_issue(
190
252
  code: str,
191
253
  path: str,
@@ -681,12 +743,15 @@ def _validate_v2_block(raw_block: Any, *, path: str) -> list[PlanValidationIssue
681
743
  issues.extend(_computed_field_issues(raw_block.get("text"), path=f"{path}.text"))
682
744
  elif block_type == "paragraph":
683
745
  issues.extend(_computed_field_issues(raw_block.get("text"), path=f"{path}.text"))
684
- 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 []):
685
750
  if isinstance(child, Mapping):
686
751
  issues.extend(
687
752
  _computed_field_issues(
688
753
  child.get("text"),
689
- path=f"{path}.children[{child_index}].text",
754
+ path=f"{path}.runs[{child_index}].text",
690
755
  )
691
756
  )
692
757
  elif block_type in {"bullets", "bullet", "numbered_list", "numberedList"}:
@@ -731,32 +796,59 @@ def create_document_from_plan(
731
796
  else DocumentStylePreset(name=str(preset or normalized.style_preset or DEFAULT_STYLE_PRESET))
732
797
  )
733
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
+ )
734
807
  tokens = style_preset.ensure_tokens(document)
735
808
  builder_document = _lower_plan_to_builder_document(normalized)
736
809
 
737
810
  if normalized.title:
738
- document.add_paragraph(
811
+ paragraph = document.add_paragraph(
739
812
  normalized.title,
740
813
  char_pr_id_ref=tokens["title"],
741
814
  inherit_style=False,
742
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
+ )
743
825
  if normalized.subtitle:
744
- document.add_paragraph(
826
+ paragraph = document.add_paragraph(
745
827
  normalized.subtitle,
746
828
  char_pr_id_ref=tokens["subtitle"],
747
829
  inherit_style=False,
748
830
  )
831
+ _format_para(document, paragraph, line_spacing=130, after_pt=10)
749
832
 
750
833
  if normalized.metadata:
751
- document.add_paragraph(
834
+ paragraph = document.add_paragraph(
752
835
  "문서 정보",
753
836
  char_pr_id_ref=tokens["heading"],
754
837
  inherit_style=False,
755
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
+ )
756
848
  _add_key_value_table(document, normalized.metadata, tokens)
757
849
 
758
850
  for block in builder_document.sections[0].children:
759
- _render_block(document, block, tokens)
851
+ _render_block(document, block, tokens, style_preset=style_preset)
760
852
 
761
853
  return document
762
854
 
@@ -956,6 +1048,14 @@ def _validate_block(raw_block: Any, *, index: int) -> list[PlanValidationIssue]:
956
1048
  elif block_type == "paragraph":
957
1049
  issues.extend(_validate_paragraph_block(raw_block, path=path))
958
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
+ )
959
1059
  elif block_type == "bullets":
960
1060
  items = _string_list(raw_block.get("items") or raw_block.get("bullets"))
961
1061
  if not items:
@@ -1042,7 +1142,37 @@ def _validate_heading_block(raw_block: Mapping[str, Any], *, path: str) -> list[
1042
1142
 
1043
1143
 
1044
1144
  def _validate_paragraph_block(raw_block: Mapping[str, Any], *, path: str) -> list[PlanValidationIssue]:
1045
- 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",)))
1046
1176
  style = str(raw_block.get("style") or "body").strip() or "body"
1047
1177
  if style not in _SUPPORTED_STYLE_TOKENS:
1048
1178
  issues.append(
@@ -1281,12 +1411,21 @@ def _normalize_block(raw_block: Any, *, index: int) -> DocumentBlock:
1281
1411
  return DocumentBlock("heading", {"level": level, "text": replace_computed_fields(text)})
1282
1412
 
1283
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
1284
1426
  return DocumentBlock(
1285
1427
  "paragraph",
1286
- {
1287
- "text": replace_computed_fields(_required_text(raw_block, "text", index)),
1288
- "style": str(raw_block.get("style") or "body").strip() or "body",
1289
- },
1428
+ data,
1290
1429
  )
1291
1430
 
1292
1431
  if block_type == "bullets":
@@ -1331,6 +1470,29 @@ def _normalize_block(raw_block: Any, *, index: int) -> DocumentBlock:
1331
1470
  return DocumentBlock("page_break", {})
1332
1471
 
1333
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
+
1334
1496
  def _normalize_v2_builder_document(plan: Mapping[str, Any]) -> BuilderDocument:
1335
1497
  metadata = plan.get("metadata") or {}
1336
1498
  builder_metadata = None
@@ -1458,9 +1620,12 @@ def _normalize_v2_block(raw_block: Any, *, path: str) -> Any:
1458
1620
  text=replace_computed_fields(str(raw_block.get("text") or "")),
1459
1621
  )
1460
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")
1461
1626
  children = tuple(
1462
1627
  child
1463
- 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 [])
1464
1629
  if isinstance(child, BuilderRun)
1465
1630
  )
1466
1631
  return BuilderParagraph(
@@ -1606,6 +1771,15 @@ def _block_to_builder_nodes(block: DocumentBlock) -> tuple[Any, ...]:
1606
1771
  ),
1607
1772
  )
1608
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
+ )
1609
1783
  return (
1610
1784
  BuilderParagraph(
1611
1785
  text=str(block.data["text"]),
@@ -1642,6 +1816,14 @@ def _block_to_builder_nodes(block: DocumentBlock) -> tuple[Any, ...]:
1642
1816
  raise ValueError(f"unsupported block type: {block.type!r}")
1643
1817
 
1644
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
+
1645
1827
  def _plan_table_column_widths(columns: list[dict[str, Any]]) -> list[int]:
1646
1828
  total = sum(max(int(column.get("widthWeight", 1)), 1) for column in columns)
1647
1829
  if total <= 0:
@@ -1707,33 +1889,134 @@ def _normalize_table_cell_value(value: Any) -> str:
1707
1889
  return normalize_cell_text(value)
1708
1890
 
1709
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
+
1710
1955
  def _render_block(
1711
1956
  document: HwpxDocument,
1712
1957
  block: Any,
1713
1958
  tokens: Mapping[str, str],
1959
+ *,
1960
+ style_preset: DocumentStylePreset,
1714
1961
  ) -> None:
1715
1962
  if isinstance(block, BuilderHeading):
1716
- document.add_paragraph(
1963
+ paragraph = document.add_paragraph(
1717
1964
  block.text,
1718
1965
  char_pr_id_ref=tokens["heading"],
1719
1966
  inherit_style=False,
1967
+ **_outline_style_refs(document, block.level),
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,
1720
1977
  )
1721
1978
  return
1722
1979
  if isinstance(block, BuilderParagraph):
1723
1980
  style = str(block.style or "body")
1724
- 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(
1725
1999
  block.text,
1726
2000
  char_pr_id_ref=tokens.get(style, tokens["body"]),
1727
2001
  inherit_style=False,
1728
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
+ )
1729
2011
  return
1730
2012
  if isinstance(block, BuilderBullet):
1731
2013
  for item in block.items:
1732
- document.add_paragraph(
2014
+ paragraph = document.add_paragraph(
1733
2015
  f"• {item}",
1734
2016
  char_pr_id_ref=tokens["bullet"],
1735
2017
  inherit_style=False,
1736
2018
  )
2019
+ _format_para(document, paragraph, line_spacing=150, after_pt=2)
1737
2020
  return
1738
2021
  if isinstance(block, BuilderTable):
1739
2022
  _add_builder_table(document, block, tokens)
@@ -1814,6 +2097,7 @@ def _add_plan_table(
1814
2097
  str(row.get(column["key"], "")),
1815
2098
  char_pr_id_ref=tokens["table_cell"],
1816
2099
  )
2100
+ _style_plan_table(document, table, header_fill=_TABLE_HEADER_FILL)
1817
2101
 
1818
2102
 
1819
2103
  def _add_builder_table(
@@ -1854,6 +2138,12 @@ def _add_builder_table(
1854
2138
  str(value),
1855
2139
  char_pr_id_ref=tokens["table_cell"],
1856
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
+ )
1857
2147
 
1858
2148
 
1859
2149
  def _set_table_cell_text(
@@ -1870,6 +2160,47 @@ def _set_table_cell_text(
1870
2160
  paragraph.char_pr_id_ref = char_pr_id_ref
1871
2161
 
1872
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
+
1873
2204
  def _apply_column_widths(table: Any, columns: list[dict[str, Any]]) -> None:
1874
2205
  total = sum(max(int(column.get("widthWeight", 1)), 1) for column in columns)
1875
2206
  if total <= 0: