python-hwpx 5.2.0__tar.gz → 5.4.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 (217) hide show
  1. {python_hwpx-5.2.0/src/python_hwpx.egg-info → python_hwpx-5.4.0}/PKG-INFO +1 -1
  2. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/pyproject.toml +1 -1
  3. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/_document/layout.py +12 -1
  4. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/_document/shapes.py +75 -0
  5. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/document.py +76 -2
  6. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/_document_primitives.py +17 -2
  7. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/document_parts.py +188 -4
  8. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/header_part.py +16 -0
  9. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/paragraph.py +133 -4
  10. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/section.py +53 -10
  11. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/table.py +15 -2
  12. {python_hwpx-5.2.0 → python_hwpx-5.4.0/src/python_hwpx.egg-info}/PKG-INFO +1 -1
  13. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/python_hwpx.egg-info/SOURCES.txt +2 -0
  14. python_hwpx-5.4.0/tests/test_authoring_defaults.py +229 -0
  15. python_hwpx-5.4.0/tests/test_chart_authoring.py +172 -0
  16. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_form_fit_integration.py +8 -0
  17. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/LICENSE +0 -0
  18. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/NOTICE +0 -0
  19. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/README.md +0 -0
  20. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/setup.cfg +0 -0
  21. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/__init__.py +0 -0
  22. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/_document/__init__.py +0 -0
  23. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/_document/_units.py +0 -0
  24. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/_document/fields.py +0 -0
  25. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/_document/media.py +0 -0
  26. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/_document/memos.py +0 -0
  27. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/_document/persistence.py +0 -0
  28. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/_document/tracked.py +0 -0
  29. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/body_patch.py +0 -0
  30. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/data/Skeleton.hwpx +0 -0
  31. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/equation/__init__.py +0 -0
  32. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/equation/authoring.py +0 -0
  33. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/equation/eqedit.py +0 -0
  34. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/equation/mathml.py +0 -0
  35. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/equation/render.py +0 -0
  36. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/equation/tokens.py +0 -0
  37. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/errors.py +0 -0
  38. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/experimental.py +0 -0
  39. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/form_fit/__init__.py +0 -0
  40. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/form_fit/apply.py +0 -0
  41. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/form_fit/engine.py +0 -0
  42. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/form_fit/measure.py +0 -0
  43. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/form_fit/policy.py +0 -0
  44. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/form_fit/report.py +0 -0
  45. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/ingest/__init__.py +0 -0
  46. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/ingest/base.py +0 -0
  47. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/ingest/hwpx_converter.py +0 -0
  48. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/layout/__init__.py +0 -0
  49. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/layout/lint.py +0 -0
  50. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/layout/report.py +0 -0
  51. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/mutation_report.py +0 -0
  52. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/opc/package.py +0 -0
  53. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/opc/relationships.py +0 -0
  54. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/opc/security.py +0 -0
  55. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/opc/xml_utils.py +0 -0
  56. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/__init__.py +0 -0
  57. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/_document_impl.py +0 -0
  58. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/body.py +0 -0
  59. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/canonical_defaults.py +0 -0
  60. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/color.py +0 -0
  61. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/common.py +0 -0
  62. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/document.py +0 -0
  63. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/header.py +0 -0
  64. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/memo.py +0 -0
  65. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/namespaces.py +0 -0
  66. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/numbering.py +0 -0
  67. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/objects.py +0 -0
  68. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/parser.py +0 -0
  69. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/run.py +0 -0
  70. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/schema.py +0 -0
  71. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/section_format.py +0 -0
  72. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/section_story.py +0 -0
  73. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/simple_parts.py +0 -0
  74. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/oxml/utils.py +0 -0
  75. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/package.py +0 -0
  76. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/patch.py +0 -0
  77. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/py.typed +0 -0
  78. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/quality/__init__.py +0 -0
  79. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/quality/ledger.py +0 -0
  80. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/quality/policy.py +0 -0
  81. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/quality/rendering.py +0 -0
  82. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/quality/report.py +0 -0
  83. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/quality/save_pipeline.py +0 -0
  84. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/table_patch.py +0 -0
  85. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/templates.py +0 -0
  86. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/__init__.py +0 -0
  87. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/_schemas/header.xsd +0 -0
  88. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/_schemas/section.xsd +0 -0
  89. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/archive_cli.py +0 -0
  90. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/doc_diff.py +0 -0
  91. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/document_viewer.py +0 -0
  92. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/exporter.py +0 -0
  93. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/generic_inventory.py +0 -0
  94. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/id_integrity.py +0 -0
  95. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/idempotence.py +0 -0
  96. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/ir_equality.py +0 -0
  97. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/layout_preview.py +0 -0
  98. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/mail_merge.py +0 -0
  99. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/markdown_export.py +0 -0
  100. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/object_finder.py +0 -0
  101. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/package_reconcile.py +0 -0
  102. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/package_validator.py +0 -0
  103. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/page_guard.py +0 -0
  104. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/read_fidelity.py +0 -0
  105. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/recover.py +0 -0
  106. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/redline.py +0 -0
  107. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/repair.py +0 -0
  108. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/report_utils.py +0 -0
  109. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/roundtrip_diff.py +0 -0
  110. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/table_cleanup.py +0 -0
  111. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/table_navigation.py +0 -0
  112. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/template_analyzer.py +0 -0
  113. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/text_extract_cli.py +0 -0
  114. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/text_extractor.py +0 -0
  115. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/toc_author.py +0 -0
  116. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/toc_fidelity.py +0 -0
  117. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/hwpx/tools/validator.py +0 -0
  118. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
  119. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/python_hwpx.egg-info/entry_points.txt +0 -0
  120. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/python_hwpx.egg-info/requires.txt +0 -0
  121. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/src/python_hwpx.egg-info/top_level.txt +0 -0
  122. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_add_control.py +0 -0
  123. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_adoption_metrics_snapshot.py +0 -0
  124. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_authoring_quality_corpus.py +0 -0
  125. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_blind_eval_fixture.py +0 -0
  126. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_byte_patch_identity.py +0 -0
  127. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_cell_line_spacing.py +0 -0
  128. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_comment_node_robustness.py +0 -0
  129. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_corpus_read_fidelity.py +0 -0
  130. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_coverage_promotion.py +0 -0
  131. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_coverage_targets.py +0 -0
  132. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_deviations_registry.py +0 -0
  133. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_doc_diff.py +0 -0
  134. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_document_context_manager.py +0 -0
  135. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_document_facade_surface.py +0 -0
  136. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_document_formatting.py +0 -0
  137. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_document_ops_runtime_boundary.py +0 -0
  138. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_document_save_api.py +0 -0
  139. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_document_viewer.py +0 -0
  140. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_documentation_code_fences.py +0 -0
  141. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_equation_authoring.py +0 -0
  142. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_equation_converter.py +0 -0
  143. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_equation_render.py +0 -0
  144. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_error_messages.py +0 -0
  145. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_evalplan_core_primitives.py +0 -0
  146. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_existing_document_format_editing.py +0 -0
  147. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_form_field_authoring.py +0 -0
  148. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_form_fields.py +0 -0
  149. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_form_fill_core_primitives.py +0 -0
  150. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_form_fit.py +0 -0
  151. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_fuzz_catalog_derive_expected.py +0 -0
  152. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_fuzz_loop.py +0 -0
  153. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_fuzz_regressions.py +0 -0
  154. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_gap_closure_tools.py +0 -0
  155. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_golden_api.py +0 -0
  156. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_hp_tab_support.py +0 -0
  157. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_hwpxlib_corpus_read.py +0 -0
  158. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_id_generator_range.py +0 -0
  159. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_image_object_workflow.py +0 -0
  160. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_ingest.py +0 -0
  161. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_inline_models.py +0 -0
  162. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_integration_hwpx_compatibility.py +0 -0
  163. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_integration_roundtrip.py +0 -0
  164. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_layout_cache_scope.py +0 -0
  165. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_layout_lint.py +0 -0
  166. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_layout_preview.py +0 -0
  167. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_library_boundary_closure.py +0 -0
  168. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_llms_txt.py +0 -0
  169. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_mail_merge_xlsx.py +0 -0
  170. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_markdown_export.py +0 -0
  171. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_memo_and_style_editing.py +0 -0
  172. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_moved_surface_hints.py +0 -0
  173. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_mutation_report.py +0 -0
  174. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_namespace_handling.py +0 -0
  175. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_new_features.py +0 -0
  176. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_opc_package.py +0 -0
  177. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_open_safety_corpus.py +0 -0
  178. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_oxml_modularization.py +0 -0
  179. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_oxml_parsing.py +0 -0
  180. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_packaging_license_metadata.py +0 -0
  181. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_packaging_py_typed.py +0 -0
  182. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_paragraph_keep_together.py +0 -0
  183. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_paragraph_section_management.py +0 -0
  184. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_product_boundary.py +0 -0
  185. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_public_artifact_hygiene.py +0 -0
  186. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_public_runtime_boundary.py +0 -0
  187. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_read_fidelity.py +0 -0
  188. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_recover_broken_zip.py +0 -0
  189. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_redline_authoring.py +0 -0
  190. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_redline_verify.py +0 -0
  191. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_release_workflow_safety.py +0 -0
  192. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_repair_repack.py +0 -0
  193. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_report_utils.py +0 -0
  194. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_repr_snapshots.py +0 -0
  195. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_roundtrip_fidelity.py +0 -0
  196. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_row_heights.py +0 -0
  197. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_save_pipeline.py +0 -0
  198. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_section_headers.py +0 -0
  199. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_set_paragraph_format_keep.py +0 -0
  200. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_shape_geometry_contract.py +0 -0
  201. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_skeleton_template_ids.py +0 -0
  202. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_split_cell.py +0 -0
  203. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_split_merged_cell.py +0 -0
  204. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_stable_surface.py +0 -0
  205. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_table_cleanup.py +0 -0
  206. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_table_navigation.py +0 -0
  207. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_table_patch.py +0 -0
  208. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_table_patch_dryrun.py +0 -0
  209. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_table_patch_m105.py +0 -0
  210. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_tables_default_border.py +0 -0
  211. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_template_analyzer_enrichment.py +0 -0
  212. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_text_extractor_annotations.py +0 -0
  213. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_toc_author.py +0 -0
  214. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_toc_fidelity.py +0 -0
  215. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_validation_severity.py +0 -0
  216. {python_hwpx-5.2.0 → python_hwpx-5.4.0}/tests/test_validator_comment_nodes.py +0 -0
  217. {python_hwpx-5.2.0 → python_hwpx-5.4.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: 5.2.0
3
+ Version: 5.4.0
4
4
  Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 문서 라이브러리
5
5
  Author: python-hwpx Maintainers
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-hwpx"
7
- version = "5.2.0"
7
+ version = "5.4.0"
8
8
  description = "한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 문서 라이브러리"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = "Apache-2.0"
@@ -425,17 +425,28 @@ def add_hyperlink(
425
425
  paragraph: HwpxOxmlParagraph | None = None,
426
426
  section: HwpxOxmlSection | None = None,
427
427
  section_index: int | None = None,
428
+ char_pr_id_ref: str | int | None = None,
428
429
  ) -> HwpxOxmlInlineObject:
429
430
  """Insert a hyperlink (fieldBegin + text + fieldEnd).
430
431
 
432
+ The display text follows the Hancom convention (blue ``#0000FF`` text
433
+ with a blue bottom underline — dominant styling across real-corpus
434
+ hyperlinks) unless ``char_pr_id_ref`` overrides it.
435
+
431
436
  Returns the ``<hp:ctrl>`` wrapper containing the ``<hp:fieldBegin>``.
432
437
  """
438
+ if char_pr_id_ref is None:
439
+ char_pr_id_ref = doc.ensure_run_style(
440
+ underline=True,
441
+ color="#0000FF",
442
+ underline_color="#0000FF",
443
+ )
433
444
  if paragraph is None:
434
445
  paragraph = doc.add_paragraph(
435
446
  "", section=section, section_index=section_index,
436
447
  include_run=False,
437
448
  )
438
- return paragraph.add_hyperlink(url, display_text)
449
+ return paragraph.add_hyperlink(url, display_text, char_pr_id_ref=char_pr_id_ref)
439
450
 
440
451
 
441
452
  def _resolve_section(
@@ -282,3 +282,78 @@ def add_equation(
282
282
  raise RuntimeError(
283
283
  "created equation was not recognized by the standard section scan"
284
284
  )
285
+
286
+
287
+ def add_chart(
288
+ doc: "HwpxDocument",
289
+ chart_xml: bytes | str,
290
+ *,
291
+ paragraph: HwpxOxmlParagraph | None = None,
292
+ section: HwpxOxmlSection | None = None,
293
+ section_index: int | None = None,
294
+ size: tuple[int, int] | None = None,
295
+ treat_as_char: bool = False,
296
+ char_pr_id_ref: str | int | None = None,
297
+ ) -> HwpxOxmlInlineObject:
298
+ """Add a chartML part and its ``<hp:chart>`` anchor, then prove recognition.
299
+
300
+ The part is stored under ``Chart/chartN.xml`` and addressed directly by
301
+ the anchor's ``chartIDRef`` — real Hancom registers chart parts in no
302
+ manifest and draws the chart from the ECMA-376 chartML alone
303
+ (specs/055-chart-authoring/evidence/p0/chart-contract.md). The chartML is
304
+ validated to parse and to carry the ``c:chartSpace`` root before any part
305
+ is written; after insertion the anchor is re-read through the standard
306
+ section scan — creation fails loudly if it did not land (no
307
+ special-casing by design).
308
+ """
309
+ from lxml import etree as _etree # type: ignore[reportAttributeAccessIssue] # lxml has no complete bundled typing
310
+
311
+ from ..oxml.namespaces import HP
312
+
313
+ _CHART_SPACE = "{http://schemas.openxmlformats.org/drawingml/2006/chart}chartSpace"
314
+
315
+ data = chart_xml.encode("utf-8") if isinstance(chart_xml, str) else bytes(chart_xml)
316
+ if not data.strip():
317
+ raise ValueError("chart_xml must be non-empty chartML")
318
+ try:
319
+ root = _etree.fromstring(data)
320
+ except _etree.XMLSyntaxError as exc:
321
+ raise ValueError(f"chart_xml is not well-formed XML: {exc}") from exc
322
+ if root.tag != _CHART_SPACE:
323
+ raise ValueError(
324
+ "chart_xml root must be the ECMA-376 c:chartSpace element, "
325
+ f"got {root.tag!r}"
326
+ )
327
+
328
+ existing = {name for name in doc._package.part_names() if name.startswith("Chart/")}
329
+ n = 1
330
+ while f"Chart/chart{n}.xml" in existing:
331
+ n += 1
332
+ part_path = f"Chart/chart{n}.xml"
333
+
334
+ if paragraph is None:
335
+ paragraph = doc.add_paragraph(
336
+ "", section=section, section_index=section_index,
337
+ include_run=False,
338
+ )
339
+ doc._package.write(part_path, data)
340
+ inline_object = paragraph.add_chart(
341
+ part_path,
342
+ size=size,
343
+ treat_as_char=treat_as_char,
344
+ char_pr_id_ref=char_pr_id_ref,
345
+ )
346
+
347
+ created_id = inline_object.element.get("id", "")
348
+ for owning_section in doc.sections:
349
+ for candidate in owning_section.element.iter(f"{HP}chart"):
350
+ if candidate.get("id") != created_id:
351
+ continue
352
+ if candidate.get("chartIDRef") != part_path:
353
+ raise RuntimeError(
354
+ "created chart anchor does not reference its part"
355
+ )
356
+ return inline_object
357
+ raise RuntimeError(
358
+ "created chart was not recognized by the standard section scan"
359
+ )
@@ -264,14 +264,21 @@ class HwpxDocument:
264
264
  border_width: str = "0.12 mm",
265
265
  fill_color: str | None = None,
266
266
  active_borders: Sequence[str] | None = None,
267
+ border_type: str = "SOLID",
267
268
  ) -> str:
268
- """Return a borderFill id matching the requested border/fill attributes."""
269
+ """Return a borderFill id matching the requested border/fill attributes.
270
+
271
+ ``border_type`` selects the OWPML line style (``SOLID``, ``DASH``,
272
+ ``DOT``, ``DOUBLE_SLIM``, ``WAVE``, …); values outside the OWPML
273
+ vocabulary are rejected.
274
+ """
269
275
 
270
276
  return self._root.ensure_border_fill(
271
277
  border_color=border_color,
272
278
  border_width=border_width,
273
279
  fill_color=fill_color,
274
280
  active_borders=active_borders,
281
+ border_type=border_type,
275
282
  )
276
283
 
277
284
  @property
@@ -594,9 +601,22 @@ class HwpxDocument:
594
601
  size: int | float | None = None,
595
602
  highlight: str | None = None,
596
603
  strike: bool | None = None,
604
+ underline_shape: str | None = None,
605
+ underline_color: str | None = None,
606
+ strike_shape: str | None = None,
607
+ ratio: int | None = None,
608
+ letter_spacing: int | None = None,
609
+ shadow: str | None = None,
610
+ script: str | None = None,
597
611
  base_char_pr_id: str | int | None = None,
598
612
  ) -> str:
599
- """Return a ``charPr`` identifier matching the requested flags."""
613
+ """Return a ``charPr`` identifier matching the requested flags.
614
+
615
+ 5.4.0 additions (render-verified vocabulary; invalid values are
616
+ rejected): ``underline_shape``/``underline_color``, ``strike_shape``,
617
+ ``ratio`` (장평 %), ``letter_spacing`` (자간 %), ``shadow`` (drop
618
+ shadow colour), ``script`` (``"sup"``/``"sub"``).
619
+ """
600
620
 
601
621
  return self._root.ensure_run_style(
602
622
  bold=bold,
@@ -607,6 +627,13 @@ class HwpxDocument:
607
627
  size=size,
608
628
  highlight=highlight,
609
629
  strike=strike,
630
+ underline_shape=underline_shape,
631
+ underline_color=underline_color,
632
+ strike_shape=strike_shape,
633
+ ratio=ratio,
634
+ letter_spacing=letter_spacing,
635
+ shadow=shadow,
636
+ script=script,
610
637
  base_char_pr_id=base_char_pr_id,
611
638
  )
612
639
 
@@ -1373,9 +1400,13 @@ class HwpxDocument:
1373
1400
  paragraph: HwpxOxmlParagraph | None = None,
1374
1401
  section: HwpxOxmlSection | None = None,
1375
1402
  section_index: int | None = None,
1403
+ char_pr_id_ref: str | int | None = None,
1376
1404
  ) -> HwpxOxmlInlineObject:
1377
1405
  """Insert a hyperlink (fieldBegin + text + fieldEnd).
1378
1406
 
1407
+ The display text follows the Hancom convention (blue underlined)
1408
+ unless ``char_pr_id_ref`` overrides it.
1409
+
1379
1410
  Returns the ``<hp:ctrl>`` wrapper containing the ``<hp:fieldBegin>``.
1380
1411
  """
1381
1412
 
@@ -1386,6 +1417,7 @@ class HwpxDocument:
1386
1417
  paragraph=paragraph,
1387
1418
  section=section,
1388
1419
  section_index=section_index,
1420
+ char_pr_id_ref=char_pr_id_ref,
1389
1421
  )
1390
1422
 
1391
1423
  def add_form_field(
@@ -1431,6 +1463,48 @@ class HwpxDocument:
1431
1463
  )
1432
1464
 
1433
1465
 
1466
+ def add_chart(
1467
+ self,
1468
+ chart_xml: bytes | str,
1469
+ *,
1470
+ paragraph: HwpxOxmlParagraph | None = None,
1471
+ section: HwpxOxmlSection | None = None,
1472
+ section_index: int | None = None,
1473
+ size: tuple[int, int] | None = None,
1474
+ treat_as_char: bool = False,
1475
+ char_pr_id_ref: str | int | None = None,
1476
+ ) -> HwpxOxmlInlineObject:
1477
+ """Insert a native chart from ECMA-376 chartML. **Experimental contract.**
1478
+
1479
+ Stores *chart_xml* as a ``Chart/chartN.xml`` package part and emits the
1480
+ real-Hancom ``<hp:chart>`` anchor referencing it via ``chartIDRef``
1481
+ (contract: ``specs/055-chart-authoring/evidence/p0/chart-contract.md``).
1482
+ Hancom draws the chart from the chartML alone — no OLE fallback or
1483
+ pre-rendered image is written. The chartML must parse and carry the
1484
+ ``c:chartSpace`` root (typed rejection otherwise), and the created
1485
+ anchor is re-read through the standard section scan — creation fails
1486
+ loudly if the standard consumer would not see it.
1487
+
1488
+ Args:
1489
+ chart_xml: ECMA-376 chartML document (``c:chartSpace``).
1490
+ paragraph: Target paragraph (e.g. inside a table cell). When
1491
+ omitted a new paragraph is appended to *section*.
1492
+ size: Optional ``(width, height)`` HWPUNIT pair for the anchor.
1493
+ treat_as_char: ``True`` places the chart inline in the text flow;
1494
+ default mirrors the render-verified gold float placement.
1495
+ """
1496
+
1497
+ return _shapes.add_chart(
1498
+ self,
1499
+ chart_xml,
1500
+ paragraph=paragraph,
1501
+ section=section,
1502
+ section_index=section_index,
1503
+ size=size,
1504
+ treat_as_char=treat_as_char,
1505
+ char_pr_id_ref=char_pr_id_ref,
1506
+ )
1507
+
1434
1508
  def add_equation(
1435
1509
  self,
1436
1510
  script: str,
@@ -422,9 +422,10 @@ def _border_fill_child_attrs(
422
422
  active: bool,
423
423
  color: str,
424
424
  width: str,
425
+ border_type: str = "SOLID",
425
426
  ) -> dict[str, str]:
426
427
  return {
427
- "type": "SOLID" if active else "NONE",
428
+ "type": border_type if active else "NONE",
428
429
  "width": width,
429
430
  "color": color,
430
431
  }
@@ -465,6 +466,7 @@ def _border_fill_matches(
465
466
  border_width: str,
466
467
  fill_color: str | None,
467
468
  active_borders: set[str],
469
+ border_type: str = "SOLID",
468
470
  ) -> bool:
469
471
  if _element_local_name(element) != "borderFill":
470
472
  return False
@@ -484,7 +486,7 @@ def _border_fill_matches(
484
486
  child = element.find(f"{_HH}{child_name}")
485
487
  if child is None:
486
488
  return False
487
- expected_type = "SOLID" if side in active_borders else "NONE"
489
+ expected_type = border_type if side in active_borders else "NONE"
488
490
  if (child.get("type") or "").upper() != expected_type:
489
491
  return False
490
492
  if _normalize_length(child.get("width")) != _normalize_length(border_width):
@@ -509,6 +511,7 @@ def _create_border_fill_element(
509
511
  border_width: str,
510
512
  fill_color: str | None,
511
513
  active_borders: set[str],
514
+ border_type: str = "SOLID",
512
515
  ) -> ET.Element:
513
516
  element = ET.Element(f"{_HH}borderFill", {"id": border_id, **_BASIC_BORDER_FILL_ATTRIBUTES})
514
517
  ET.SubElement(element, f"{_HH}slash", {"type": "NONE", "Crooked": "0", "isCounter": "0"})
@@ -521,6 +524,7 @@ def _create_border_fill_element(
521
524
  active=side in active_borders,
522
525
  color=border_color,
523
526
  width=border_width,
527
+ border_type=border_type,
524
528
  ),
525
529
  )
526
530
  ET.SubElement(
@@ -579,6 +583,17 @@ def _default_cell_margin_attributes() -> dict[str, str]:
579
583
  return {"left": "0", "right": "0", "top": "0", "bottom": "0"}
580
584
 
581
585
 
586
+ def _default_cell_inner_margin_attributes() -> dict[str, str]:
587
+ """Hancom's new-table cell padding: 1.8 mm left/right, 0.5 mm top/bottom.
588
+
589
+ Measured from real Hancom-authored gold documents
590
+ (specs/056-authoring-fidelity-audit — the audit found our former 0
591
+ padding makes text hug the borders, visibly unlike a Hancom table).
592
+ """
593
+
594
+ return {"left": "510", "right": "510", "top": "141", "bottom": "141"}
595
+
596
+
582
597
  def _get_int_attr(element: ET.Element, name: str, default: int = 0) -> int:
583
598
  """Return *name* attribute of *element* as an integer."""
584
599
 
@@ -59,6 +59,13 @@ class _RunStyleSpec:
59
59
  height: str | None
60
60
  strike: bool | None
61
61
  font_ref: dict[str, str] | None
62
+ underline_shape: str | None = None
63
+ underline_color: str | None = None
64
+ strike_shape: str | None = None
65
+ ratio: int | None = None
66
+ letter_spacing: int | None = None
67
+ shadow_color: str | None = None
68
+ script: str | None = None
62
69
 
63
70
 
64
71
  def _run_style_element_flags(element: ET.Element) -> tuple[bool, bool, bool]:
@@ -78,6 +85,81 @@ def _run_style_element_strike(element: ET.Element) -> bool:
78
85
  return strike_element.get("shape", "").upper() != "NONE"
79
86
 
80
87
 
88
+ def _validated_line_shape(
89
+ value: str | None, vocabulary: frozenset[str], label: str
90
+ ) -> str | None:
91
+ if value is None:
92
+ return None
93
+ normalized = str(value).upper()
94
+ if normalized not in vocabulary:
95
+ raise ValueError(f"unsupported {label} {value!r}")
96
+ return normalized
97
+
98
+
99
+ def _run_style_underline_matches(element: ET.Element, spec: _RunStyleSpec) -> bool:
100
+ if spec.underline_shape is None and spec.underline_color is None:
101
+ return True
102
+ underline = element.find(f"{_HH}underline")
103
+ if underline is None:
104
+ return False
105
+ if spec.underline_shape is not None and (
106
+ underline.get("shape", "").upper() != spec.underline_shape
107
+ ):
108
+ return False
109
+ if spec.underline_color is not None and (
110
+ (underline.get("color") or "").upper() != spec.underline_color.upper()
111
+ ):
112
+ return False
113
+ return True
114
+
115
+
116
+ def _run_style_lang_value_matches(
117
+ element: ET.Element, tag: str, value: int | None
118
+ ) -> bool:
119
+ if value is None:
120
+ return True
121
+ node = element.find(f"{_HH}{tag}")
122
+ return node is not None and node.get("hangul") == str(value)
123
+
124
+
125
+ def _run_style_extensions_match(element: ET.Element, spec: _RunStyleSpec) -> bool:
126
+ if not _run_style_underline_matches(element, spec):
127
+ return False
128
+ if spec.strike_shape is not None:
129
+ strike_el = element.find(f"{_HH}strikeout")
130
+ if strike_el is None or strike_el.get("shape", "").upper() != spec.strike_shape:
131
+ return False
132
+ if not _run_style_lang_value_matches(element, "ratio", spec.ratio):
133
+ return False
134
+ if not _run_style_lang_value_matches(element, "spacing", spec.letter_spacing):
135
+ return False
136
+ if not _run_style_shadow_matches(element, spec.shadow_color):
137
+ return False
138
+ if not _run_style_script_matches(element, spec.script):
139
+ return False
140
+ return True
141
+
142
+
143
+ def _run_style_shadow_matches(element: ET.Element, shadow_color: str | None) -> bool:
144
+ if shadow_color is None:
145
+ return True
146
+ shadow_el = element.find(f"{_HH}shadow")
147
+ if shadow_el is None or shadow_el.get("type", "").upper() == "NONE":
148
+ return False
149
+ return (shadow_el.get("color") or "").upper() == shadow_color.upper()
150
+
151
+
152
+ def _run_style_script_matches(element: ET.Element, script: str | None) -> bool:
153
+ if script is None:
154
+ return True
155
+ if not _run_style_lang_value_matches(element, "relSz", 67):
156
+ return False
157
+ off_el = element.find(f"{_HH}offset")
158
+ # 실한컴 렌더 실측: offset 음수=위로(위첨자), 양수=아래로(아래첨자).
159
+ wanted_offset = "-30" if script == "sup" else "30"
160
+ return off_el is not None and off_el.get("hangul") == wanted_offset
161
+
162
+
81
163
  def _run_style_predicate(element: ET.Element, spec: _RunStyleSpec) -> bool:
82
164
  if _run_style_element_flags(element) != spec.flags:
83
165
  return False
@@ -92,6 +174,8 @@ def _run_style_predicate(element: ET.Element, spec: _RunStyleSpec) -> bool:
92
174
  return False
93
175
  if spec.strike is not None and _run_style_element_strike(element) != bool(spec.strike):
94
176
  return False
177
+ if not _run_style_extensions_match(element, spec):
178
+ return False
95
179
  if spec.font_ref is not None:
96
180
  font_ref = element.find(f"{_HH}fontRef")
97
181
  if font_ref is None:
@@ -127,9 +211,10 @@ def _run_style_apply_underline(
127
211
  ) -> None:
128
212
  underline_attrs = dict(base_underline_attrs)
129
213
  if want_underline:
130
- underline_attrs.setdefault("type", "SOLID")
131
- if underline_attrs.get("type", "").upper() == "NONE":
132
- underline_attrs["type"] = "SOLID"
214
+ # 실한컴 gold 관례: hh:underline type 위치 어휘(BOTTOM/CENTER/TOP).
215
+ underline_attrs.setdefault("type", "BOTTOM")
216
+ if underline_attrs.get("type", "").upper() in ("NONE", "SOLID"):
217
+ underline_attrs["type"] = "BOTTOM"
133
218
  underline_attrs.setdefault(
134
219
  "shape", base_underline_attrs.get("shape", "SOLID")
135
220
  )
@@ -159,6 +244,56 @@ def _run_style_apply_strikeout(
159
244
  _append_child(element, f"{_HH}strikeout", strike_attrs)
160
245
 
161
246
 
247
+ def _run_style_set_lang_values(element: ET.Element, tag: str, value: int) -> None:
248
+ node = element.find(f"{_HH}{tag}")
249
+ if node is None:
250
+ node = _append_child(element, f"{_HH}{tag}")
251
+ for lang in ("hangul", "latin", "hanja", "japanese", "other", "symbol", "user"):
252
+ node.set(lang, str(value))
253
+
254
+
255
+ def _run_style_apply_underline_extension(
256
+ element: ET.Element, spec: _RunStyleSpec
257
+ ) -> None:
258
+ if spec.underline_shape is None and spec.underline_color is None:
259
+ return
260
+ underline = element.find(f"{_HH}underline")
261
+ if underline is None:
262
+ return
263
+ if underline.get("type", "").upper() == "NONE":
264
+ underline.set("type", "BOTTOM")
265
+ if spec.underline_shape is not None:
266
+ underline.set("shape", spec.underline_shape)
267
+ if spec.underline_color is not None:
268
+ underline.set("color", spec.underline_color)
269
+
270
+
271
+ def _run_style_apply_extensions(element: ET.Element, spec: _RunStyleSpec) -> None:
272
+ _run_style_apply_underline_extension(element, spec)
273
+ if spec.strike_shape is not None:
274
+ strike_el = element.find(f"{_HH}strikeout")
275
+ if strike_el is None:
276
+ strike_el = _append_child(element, f"{_HH}strikeout")
277
+ strike_el.set("shape", spec.strike_shape)
278
+ if not strike_el.get("color"):
279
+ strike_el.set("color", "#000000")
280
+ if spec.ratio is not None:
281
+ _run_style_set_lang_values(element, "ratio", spec.ratio)
282
+ if spec.letter_spacing is not None:
283
+ _run_style_set_lang_values(element, "spacing", spec.letter_spacing)
284
+ if spec.shadow_color is not None:
285
+ shadow_el = element.find(f"{_HH}shadow")
286
+ if shadow_el is None:
287
+ shadow_el = _append_child(element, f"{_HH}shadow")
288
+ shadow_el.set("type", "DROP")
289
+ shadow_el.set("color", spec.shadow_color)
290
+ shadow_el.set("offsetX", "12")
291
+ shadow_el.set("offsetY", "12")
292
+ if spec.script is not None:
293
+ _run_style_set_lang_values(element, "relSz", 67)
294
+ _run_style_set_lang_values(element, "offset", -30 if spec.script == "sup" else 30)
295
+
296
+
162
297
  def _run_style_modifier(element: ET.Element, spec: _RunStyleSpec) -> None:
163
298
  underline_nodes = list(element.findall(f"{_HH}underline"))
164
299
  base_underline_attrs = (
@@ -186,6 +321,8 @@ def _run_style_modifier(element: ET.Element, spec: _RunStyleSpec) -> None:
186
321
  _run_style_apply_underline(element, base_underline_attrs, spec.flags[2])
187
322
  _run_style_apply_strikeout(element, base_strike_attrs, spec.strike)
188
323
 
324
+ _run_style_apply_extensions(element, spec)
325
+
189
326
 
190
327
  class HwpxOxmlDocument:
191
328
  """Aggregates the XML parts that make up an HWPX document."""
@@ -357,6 +494,12 @@ class HwpxOxmlDocument:
357
494
  return None
358
495
  return cache.get(normalized)
359
496
 
497
+ _UNDERLINE_SHAPES = frozenset({
498
+ "SOLID", "DASH", "DOT", "DASH_DOT", "DASH_DOT_DOT", "LONG_DASH",
499
+ "CIRCLE", "DOUBLE_SLIM", "SLIM_THICK", "THICK_SLIM",
500
+ "SLIM_THICK_SLIM", "WAVE", "DOUBLEWAVE",
501
+ })
502
+
360
503
  def ensure_run_style(
361
504
  self,
362
505
  *,
@@ -368,13 +511,45 @@ class HwpxOxmlDocument:
368
511
  size: int | float | None = None,
369
512
  highlight: str | None = None,
370
513
  strike: bool | None = None,
514
+ underline_shape: str | None = None,
515
+ underline_color: str | None = None,
516
+ strike_shape: str | None = None,
517
+ ratio: int | None = None,
518
+ letter_spacing: int | None = None,
519
+ shadow: str | None = None,
520
+ script: str | None = None,
371
521
  base_char_pr_id: str | int | None = None,
372
522
  ) -> str:
373
- """Return a char property identifier matching the requested flags."""
523
+ """Return a char property identifier matching the requested flags.
524
+
525
+ The 5.4.0 additions mirror what the fidelity audit render-verified on
526
+ real Hancom: ``underline_shape``/``underline_color`` (implies an
527
+ underline), ``strike_shape`` (implies a strikeout), ``ratio`` (장평 %),
528
+ ``letter_spacing`` (자간 %), ``shadow`` (drop-shadow colour), and
529
+ ``script`` (``"sup"``/``"sub"``). Values outside the OWPML vocabulary
530
+ are rejected — no silent approximation.
531
+ """
374
532
 
375
533
  if not self._headers:
376
534
  raise ValueError("document does not contain any headers")
377
535
 
536
+ normalized_underline_shape = _validated_line_shape(
537
+ underline_shape, self._UNDERLINE_SHAPES, "underline_shape"
538
+ )
539
+ if normalized_underline_shape is not None or underline_color is not None:
540
+ underline = True
541
+ normalized_strike_shape = _validated_line_shape(
542
+ strike_shape, self._UNDERLINE_SHAPES, "strike_shape"
543
+ )
544
+ if normalized_strike_shape is not None:
545
+ strike = True
546
+ if ratio is not None and not 10 <= int(ratio) <= 400:
547
+ raise ValueError("ratio must be a percentage between 10 and 400")
548
+ if letter_spacing is not None and not -50 <= int(letter_spacing) <= 100:
549
+ raise ValueError("letter_spacing must be between -50 and 100")
550
+ if script is not None and script not in ("sup", "sub"):
551
+ raise ValueError('script must be "sup" or "sub"')
552
+
378
553
  header = self._headers[0]
379
554
  spec = _RunStyleSpec(
380
555
  flags=(bool(bold), bool(italic), bool(underline)),
@@ -383,6 +558,13 @@ class HwpxOxmlDocument:
383
558
  height=_char_height_from_points(size),
384
559
  strike=strike,
385
560
  font_ref=header.font_ref_for_face(font) if font is not None else None,
561
+ underline_shape=normalized_underline_shape,
562
+ underline_color=_normalize_color(underline_color),
563
+ strike_shape=normalized_strike_shape,
564
+ ratio=int(ratio) if ratio is not None else None,
565
+ letter_spacing=int(letter_spacing) if letter_spacing is not None else None,
566
+ shadow_color=_normalize_color(shadow),
567
+ script=script,
386
568
  )
387
569
  element = header.ensure_char_property(
388
570
  predicate=lambda el: _run_style_predicate(el, spec),
@@ -425,6 +607,7 @@ class HwpxOxmlDocument:
425
607
  border_width: str = "0.12 mm",
426
608
  fill_color: str | None = None,
427
609
  active_borders: Iterable[str] | None = None,
610
+ border_type: str = "SOLID",
428
611
  ) -> str:
429
612
  if not self._headers:
430
613
  return "0"
@@ -433,6 +616,7 @@ class HwpxOxmlDocument:
433
616
  border_width=border_width,
434
617
  fill_color=fill_color,
435
618
  active_borders=active_borders,
619
+ border_type=border_type,
436
620
  )
437
621
 
438
622
  def ensure_shading_border_fill(
@@ -898,6 +898,13 @@ class HwpxOxmlHeader:
898
898
  self.mark_dirty()
899
899
  return new_id
900
900
 
901
+ _BORDER_LINE_TYPES = frozenset({
902
+ "SOLID", "DASH", "DOT", "DASH_DOT", "DASH_DOT_DOT", "LONG_DASH",
903
+ "CIRCLE", "DOUBLE_SLIM", "SLIM_THICK", "THICK_SLIM", "SLIM_THICK_SLIM",
904
+ "WAVE", "DOUBLEWAVE", "THICK_3D", "THICK_3D_REVERSE_LIGHTING",
905
+ "SLIM_3D", "SLIM_3D_REVERSE_LIGHTING",
906
+ })
907
+
901
908
  def ensure_border_fill(
902
909
  self,
903
910
  *,
@@ -905,11 +912,18 @@ class HwpxOxmlHeader:
905
912
  border_width: str = "0.12 mm",
906
913
  fill_color: str | None = None,
907
914
  active_borders: Iterable[str] | None = None,
915
+ border_type: str = "SOLID",
908
916
  ) -> str:
909
917
  element = self._border_fills_element(create=True)
910
918
  if element is None: # pragma: no cover - defensive branch
911
919
  raise RuntimeError("failed to create <borderFills> element")
912
920
 
921
+ normalized_border_type = str(border_type or "SOLID").upper()
922
+ if normalized_border_type not in self._BORDER_LINE_TYPES:
923
+ raise ValueError(
924
+ f"unsupported border_type {border_type!r}; expected one of "
925
+ + ", ".join(sorted(self._BORDER_LINE_TYPES))
926
+ )
913
927
  normalized_border_color = _normalize_color(border_color) or "#BFBFBF"
914
928
  normalized_border_width = str(border_width or "0.12 mm")
915
929
  normalized_active_borders = _normalize_border_side_names(active_borders)
@@ -922,6 +936,7 @@ class HwpxOxmlHeader:
922
936
  border_width=normalized_border_width,
923
937
  fill_color=normalized_fill_color,
924
938
  active_borders=normalized_active_borders,
939
+ border_type=normalized_border_type,
925
940
  ):
926
941
  border_id = border_fill.get("id")
927
942
  if border_id:
@@ -934,6 +949,7 @@ class HwpxOxmlHeader:
934
949
  border_width=normalized_border_width,
935
950
  fill_color=normalized_fill_color,
936
951
  active_borders=normalized_active_borders,
952
+ border_type=normalized_border_type,
937
953
  )
938
954
  if isinstance(element, LET._Element):
939
955
  new_border_fill = LET.fromstring(ET.tostring(new_border_fill, encoding="utf-8"))