python-hwpx 2.23.0__tar.gz → 2.24.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 (291) hide show
  1. {python_hwpx-2.23.0/src/python_hwpx.egg-info → python_hwpx-2.24.0}/PKG-INFO +2 -1
  2. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/README.md +1 -0
  3. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/pyproject.toml +1 -1
  4. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/__init__.py +18 -0
  5. python_hwpx-2.24.0/src/hwpx/body_patch.py +573 -0
  6. python_hwpx-2.24.0/src/hwpx/evalplan_fill.py +1590 -0
  7. python_hwpx-2.24.0/src/hwpx/fill_residue.py +163 -0
  8. python_hwpx-2.24.0/src/hwpx/formfill_quality.py +823 -0
  9. python_hwpx-2.24.0/src/hwpx/guidance_scan.py +519 -0
  10. python_hwpx-2.24.0/src/hwpx/ingest/__init__.py +26 -0
  11. python_hwpx-2.24.0/src/hwpx/ingest/base.py +230 -0
  12. python_hwpx-2.24.0/src/hwpx/ingest/hwpx_converter.py +127 -0
  13. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/table_patch.py +633 -15
  14. {python_hwpx-2.23.0 → python_hwpx-2.24.0/src/python_hwpx.egg-info}/PKG-INFO +2 -1
  15. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/python_hwpx.egg-info/SOURCES.txt +19 -0
  16. python_hwpx-2.24.0/tests/test_body_patch.py +223 -0
  17. python_hwpx-2.24.0/tests/test_cell_line_spacing.py +90 -0
  18. python_hwpx-2.24.0/tests/test_evalplan_fill.py +774 -0
  19. python_hwpx-2.24.0/tests/test_fill_residue.py +71 -0
  20. python_hwpx-2.24.0/tests/test_formfill_quality.py +217 -0
  21. python_hwpx-2.24.0/tests/test_guidance_scan.py +160 -0
  22. python_hwpx-2.24.0/tests/test_ingest.py +65 -0
  23. python_hwpx-2.24.0/tests/test_row_heights.py +76 -0
  24. python_hwpx-2.24.0/tests/test_split_cell.py +113 -0
  25. python_hwpx-2.24.0/tests/test_table_patch_dryrun.py +64 -0
  26. python_hwpx-2.24.0/tests/test_table_patch_m105.py +240 -0
  27. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/LICENSE +0 -0
  28. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/NOTICE +0 -0
  29. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/setup.cfg +0 -0
  30. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/authoring.py +0 -0
  31. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/builder/__init__.py +0 -0
  32. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/builder/core.py +0 -0
  33. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/builder/report.py +0 -0
  34. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/conformance/__init__.py +0 -0
  35. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/conformance/badges.py +0 -0
  36. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/conformance/corpus/corpus.json +0 -0
  37. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/conformance/corpus/meeting_summary.hwpx +0 -0
  38. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/conformance/corpus/notice.hwpx +0 -0
  39. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/conformance/corpus/report_table.hwpx +0 -0
  40. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/conformance/corpus.py +0 -0
  41. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/conformance/report.py +0 -0
  42. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/conformance/roundtrip_batch.py +0 -0
  43. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/conformance/runner.py +0 -0
  44. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/data/Skeleton.hwpx +0 -0
  45. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/__init__.py +0 -0
  46. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/_support.py +0 -0
  47. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/composer.py +0 -0
  48. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/harvest.py +0 -0
  49. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/plan.py +0 -0
  50. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profile.py +0 -0
  51. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/application_form/fragments/body.xml +0 -0
  52. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/application_form/fragments/heading.xml +0 -0
  53. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/application_form/fragments/info_table.xml +0 -0
  54. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/application_form/fragments/title.xml +0 -0
  55. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/application_form/profile.json +0 -0
  56. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/application_form/template.hwpx +0 -0
  57. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/home_notice/fragments/body.xml +0 -0
  58. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/home_notice/fragments/heading.xml +0 -0
  59. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/home_notice/fragments/title.xml +0 -0
  60. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/home_notice/profile.json +0 -0
  61. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/home_notice/template.hwpx +0 -0
  62. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/official_notice/fragments/body.xml +0 -0
  63. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/official_notice/fragments/heading.xml +0 -0
  64. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/official_notice/fragments/info_table.xml +0 -0
  65. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/official_notice/fragments/title.xml +0 -0
  66. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/official_notice/profile.json +0 -0
  67. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/official_notice/template.hwpx +0 -0
  68. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/report/fragments/body.xml +0 -0
  69. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/report/fragments/heading.xml +0 -0
  70. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/report/fragments/info_table.xml +0 -0
  71. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/report/fragments/title.xml +0 -0
  72. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/report/profile.json +0 -0
  73. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/profiles/report/template.hwpx +0 -0
  74. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/design/validator.py +0 -0
  75. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/document.py +0 -0
  76. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/exam/__init__.py +0 -0
  77. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/exam/compose.py +0 -0
  78. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/exam/ir.py +0 -0
  79. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/exam/measure.py +0 -0
  80. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/exam/parser.py +0 -0
  81. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/exam/profile.py +0 -0
  82. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/form_fill.py +0 -0
  83. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/form_fit/__init__.py +0 -0
  84. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/form_fit/apply.py +0 -0
  85. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/form_fit/engine.py +0 -0
  86. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/form_fit/measure.py +0 -0
  87. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/form_fit/policy.py +0 -0
  88. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/form_fit/report.py +0 -0
  89. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/form_fit/seal.py +0 -0
  90. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/form_fit/wordbox.py +0 -0
  91. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/layout/__init__.py +0 -0
  92. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/layout/lint.py +0 -0
  93. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/layout/report.py +0 -0
  94. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/opc/package.py +0 -0
  95. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/opc/relationships.py +0 -0
  96. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/opc/security.py +0 -0
  97. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/opc/xml_utils.py +0 -0
  98. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/__init__.py +0 -0
  99. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/_document_impl.py +0 -0
  100. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/body.py +0 -0
  101. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/canonical_defaults.py +0 -0
  102. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/common.py +0 -0
  103. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/document.py +0 -0
  104. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/header.py +0 -0
  105. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/header_part.py +0 -0
  106. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/memo.py +0 -0
  107. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/namespaces.py +0 -0
  108. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/numbering.py +0 -0
  109. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/objects.py +0 -0
  110. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/paragraph.py +0 -0
  111. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/parser.py +0 -0
  112. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/run.py +0 -0
  113. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/schema.py +0 -0
  114. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/section.py +0 -0
  115. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/simple_parts.py +0 -0
  116. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/table.py +0 -0
  117. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/oxml/utils.py +0 -0
  118. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/package.py +0 -0
  119. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/patch.py +0 -0
  120. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/presets/__init__.py +0 -0
  121. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/presets/proposal.py +0 -0
  122. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/py.typed +0 -0
  123. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/quality/__init__.py +0 -0
  124. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/quality/ledger.py +0 -0
  125. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/quality/policy.py +0 -0
  126. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/quality/report.py +0 -0
  127. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/quality/save_pipeline.py +0 -0
  128. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/template_formfit.py +0 -0
  129. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/templates.py +0 -0
  130. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/__init__.py +0 -0
  131. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/_schemas/header.xsd +0 -0
  132. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/_schemas/section.xsd +0 -0
  133. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/advanced_generators.py +0 -0
  134. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/archive_cli.py +0 -0
  135. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/doc_diff.py +0 -0
  136. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/exporter.py +0 -0
  137. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/fuzz/__init__.py +0 -0
  138. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/fuzz/__main__.py +0 -0
  139. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/fuzz/catalog.py +0 -0
  140. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/fuzz/generator.py +0 -0
  141. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/fuzz/minimize.py +0 -0
  142. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/fuzz/runner.py +0 -0
  143. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/generic_inventory.py +0 -0
  144. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/id_integrity.py +0 -0
  145. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/idempotence.py +0 -0
  146. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/ir_equality.py +0 -0
  147. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/layout_preview.py +0 -0
  148. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/mail_merge.py +0 -0
  149. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/markdown_export.py +0 -0
  150. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/object_finder.py +0 -0
  151. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/official_lint.py +0 -0
  152. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/package_reconcile.py +0 -0
  153. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/package_validator.py +0 -0
  154. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/page_guard.py +0 -0
  155. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/pii.py +0 -0
  156. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/read_fidelity.py +0 -0
  157. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/recover.py +0 -0
  158. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/redline.py +0 -0
  159. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/repair.py +0 -0
  160. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/report_parser.py +0 -0
  161. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/report_utils.py +0 -0
  162. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/roundtrip_diff.py +0 -0
  163. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/style_profile.py +0 -0
  164. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/table_cleanup.py +0 -0
  165. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/table_compute.py +0 -0
  166. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/table_navigation.py +0 -0
  167. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/template_analyzer.py +0 -0
  168. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/text_extract_cli.py +0 -0
  169. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/text_extractor.py +0 -0
  170. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/toc_author.py +0 -0
  171. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/toc_fidelity.py +0 -0
  172. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/tools/validator.py +0 -0
  173. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/visual/__init__.py +0 -0
  174. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/visual/_hancom_open_rate.ps1 +0 -0
  175. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/visual/_refresh_hwpx_mac.applescript +0 -0
  176. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/visual/_render_hwpx.ps1 +0 -0
  177. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/visual/_render_hwpx_mac.applescript +0 -0
  178. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/visual/detectors.py +0 -0
  179. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/visual/diff.py +0 -0
  180. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/visual/masks.py +0 -0
  181. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/visual/oracle.py +0 -0
  182. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/hwpx/visual/report.py +0 -0
  183. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
  184. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/python_hwpx.egg-info/entry_points.txt +0 -0
  185. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/python_hwpx.egg-info/requires.txt +0 -0
  186. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/src/python_hwpx.egg-info/top_level.txt +0 -0
  187. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_advanced_generators.py +0 -0
  188. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_authoring_profile_routing.py +0 -0
  189. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_authoring_profile_routing_oracle.py +0 -0
  190. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_authoring_render_check.py +0 -0
  191. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_builder_core.py +0 -0
  192. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_builder_plan_v2.py +0 -0
  193. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_builder_vertical_slice.py +0 -0
  194. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_comment_node_robustness.py +0 -0
  195. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_conformance.py +0 -0
  196. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_corpus_read_fidelity.py +0 -0
  197. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_coverage_promotion.py +0 -0
  198. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_coverage_targets.py +0 -0
  199. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_design_builder.py +0 -0
  200. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_deviations_registry.py +0 -0
  201. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_doc_diff.py +0 -0
  202. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_document_context_manager.py +0 -0
  203. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_document_formatting.py +0 -0
  204. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_document_plan.py +0 -0
  205. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_document_plan_computed_fields.py +0 -0
  206. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_document_save_api.py +0 -0
  207. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_exam_compose.py +0 -0
  208. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_exam_compose_oracle.py +0 -0
  209. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_exam_fixtures.py +0 -0
  210. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_exam_ir.py +0 -0
  211. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_exam_measure.py +0 -0
  212. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_exam_parser.py +0 -0
  213. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_exam_profile.py +0 -0
  214. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_existing_document_format_editing.py +0 -0
  215. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_form_fields.py +0 -0
  216. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_form_fill_split_run.py +0 -0
  217. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_form_fit.py +0 -0
  218. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_form_fit_integration.py +0 -0
  219. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_form_fit_seal.py +0 -0
  220. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_form_fit_seal_placement.py +0 -0
  221. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_form_fit_wordbox.py +0 -0
  222. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_fuzz_loop.py +0 -0
  223. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_fuzz_regressions.py +0 -0
  224. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_gap_closure_tools.py +0 -0
  225. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_government_report_preset.py +0 -0
  226. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_government_table_profile.py +0 -0
  227. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_hp_tab_support.py +0 -0
  228. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_hwpxlib_corpus_read.py +0 -0
  229. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_id_generator_range.py +0 -0
  230. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_id_integrity.py +0 -0
  231. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_image_object_workflow.py +0 -0
  232. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_inline_models.py +0 -0
  233. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_integration_hwpx_compatibility.py +0 -0
  234. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_integration_roundtrip.py +0 -0
  235. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_kordoc_absorption.py +0 -0
  236. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_layout_lint.py +0 -0
  237. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_layout_preview.py +0 -0
  238. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_mail_merge_fit.py +0 -0
  239. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_mail_merge_table_compute.py +0 -0
  240. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_mail_merge_xlsx.py +0 -0
  241. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_markdown_export.py +0 -0
  242. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_memo_and_style_editing.py +0 -0
  243. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_namespace_handling.py +0 -0
  244. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_new_features.py +0 -0
  245. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_official_document_style.py +0 -0
  246. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_official_lint_gongmun_gate.py +0 -0
  247. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_official_lint_tableaware.py +0 -0
  248. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_opc_package.py +0 -0
  249. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_open_safety_corpus.py +0 -0
  250. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_openrate.py +0 -0
  251. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_oxml_parsing.py +0 -0
  252. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_packaging_license_metadata.py +0 -0
  253. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_packaging_py_typed.py +0 -0
  254. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_paragraph_keep_together.py +0 -0
  255. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_paragraph_section_management.py +0 -0
  256. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_pii.py +0 -0
  257. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_pii_structural.py +0 -0
  258. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_pii_wiring.py +0 -0
  259. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_proposal_preset.py +0 -0
  260. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_question_split_detector.py +0 -0
  261. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_read_fidelity.py +0 -0
  262. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_recover_broken_zip.py +0 -0
  263. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_redline_authoring.py +0 -0
  264. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_redline_verify.py +0 -0
  265. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_repair_repack.py +0 -0
  266. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_report_parser.py +0 -0
  267. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_report_utils.py +0 -0
  268. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_repr_snapshots.py +0 -0
  269. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_rhwp_t1_gates.py +0 -0
  270. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_rhwp_t2_verification.py +0 -0
  271. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_roundtrip_fidelity.py +0 -0
  272. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_save_pipeline.py +0 -0
  273. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_save_pipeline_no_bypass.py +0 -0
  274. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_section_headers.py +0 -0
  275. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_set_paragraph_format_keep.py +0 -0
  276. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_skeleton_template_ids.py +0 -0
  277. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_split_merged_cell.py +0 -0
  278. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_style_profile.py +0 -0
  279. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_table_cleanup.py +0 -0
  280. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_table_navigation.py +0 -0
  281. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_table_patch.py +0 -0
  282. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_tables_default_border.py +0 -0
  283. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_template_analyzer_enrichment.py +0 -0
  284. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_template_formfit.py +0 -0
  285. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_text_extractor_annotations.py +0 -0
  286. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_toc_author.py +0 -0
  287. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_toc_fidelity.py +0 -0
  288. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_validation_severity.py +0 -0
  289. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_validator_comment_nodes.py +0 -0
  290. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_version_metadata.py +0 -0
  291. {python_hwpx-2.23.0 → python_hwpx-2.24.0}/tests/test_visual_oracle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 2.23.0
3
+ Version: 2.24.0
4
4
  Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리
5
5
  Author: python-hwpx Maintainers
6
6
  License-Expression: Apache-2.0
@@ -105,6 +105,7 @@ new.save_to_path("새문서.hwpx")
105
105
  ### 🔍 읽기 · 추출
106
106
  - 텍스트/HTML/Markdown 내보내기 — `export_text()` · `export_html()` · `export_markdown()`
107
107
  - **풍부한 Markdown** — `export_rich_markdown()`은 인라인 서식(`**굵게**`·`*기울임*`·`~~취소선~~`), 중첩 표(colspan/rowspan 안전), 도형 텍스트, 이미지, 각주/미주, 하이퍼링크, 제목(`#`/`##`) 자동 감지까지 보존
108
+ - **문서 ingest 게이트웨이** — `hwpx.ingest.DocumentIngestor`가 HWPX를 감지해 rich Markdown과 섹션/표 메타데이터로 정규화
108
109
  - `TextExtractor` / `ObjectFinder` — 섹션·문단 순회, 태그·속성·XPath로 객체 탐색 (`hp:tab`은 `\t`로 보존, roundtrip 안전)
109
110
 
110
111
  ```python
@@ -63,6 +63,7 @@ new.save_to_path("새문서.hwpx")
63
63
  ### 🔍 읽기 · 추출
64
64
  - 텍스트/HTML/Markdown 내보내기 — `export_text()` · `export_html()` · `export_markdown()`
65
65
  - **풍부한 Markdown** — `export_rich_markdown()`은 인라인 서식(`**굵게**`·`*기울임*`·`~~취소선~~`), 중첩 표(colspan/rowspan 안전), 도형 텍스트, 이미지, 각주/미주, 하이퍼링크, 제목(`#`/`##`) 자동 감지까지 보존
66
+ - **문서 ingest 게이트웨이** — `hwpx.ingest.DocumentIngestor`가 HWPX를 감지해 rich Markdown과 섹션/표 메타데이터로 정규화
66
67
  - `TextExtractor` / `ObjectFinder` — 섹션·문단 순회, 태그·속성·XPath로 객체 탐색 (`hp:tab`은 `\t`로 보존, roundtrip 안전)
67
68
 
68
69
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-hwpx"
7
- version = "2.23.0"
7
+ version = "2.24.0"
8
8
  description = "한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = "Apache-2.0"
@@ -75,6 +75,16 @@ from .tools.layout_preview import (
75
75
  PreviewPage,
76
76
  render_layout_preview,
77
77
  )
78
+ from .ingest import (
79
+ ConversionAttempt,
80
+ DocumentConverter,
81
+ DocumentIngestError,
82
+ DocumentIngestResult,
83
+ DocumentIngestor,
84
+ DocumentSourceInfo,
85
+ HwpxMarkdownConverter,
86
+ UnsupportedDocumentFormat,
87
+ )
78
88
  from .patch import (
79
89
  BytePreservingPatchResult,
80
90
  ParagraphTextPatch,
@@ -123,8 +133,14 @@ __all__ = [
123
133
  "DEFAULT_STYLE_PRESET",
124
134
  "DOCUMENT_PLAN_SCHEMA_VERSION",
125
135
  "get_document_plan_schema",
136
+ "ConversionAttempt",
126
137
  "DocumentBlock",
138
+ "DocumentConverter",
139
+ "DocumentIngestError",
140
+ "DocumentIngestResult",
141
+ "DocumentIngestor",
127
142
  "DocumentPlan",
143
+ "DocumentSourceInfo",
128
144
  "DocumentStylePreset",
129
145
  "EditorOpenSafetyReport",
130
146
  "ParagraphInfo",
@@ -141,6 +157,7 @@ __all__ = [
141
157
  "TEMPLATE_FORMFIT_PLAN_SCHEMA_VERSION",
142
158
  "TextExtractor",
143
159
  "FoundElement",
160
+ "HwpxMarkdownConverter",
144
161
  "ObjectFinder",
145
162
  "OFFICIAL_DOCUMENT_STYLE_REPORT_VERSION",
146
163
  "DOC_DIFF_REPORT_VERSION",
@@ -150,6 +167,7 @@ __all__ = [
150
167
  "STYLE_PROFILE_SCHEMA_VERSION",
151
168
  "TEMPLATE_REGISTRY_SCHEMA_VERSION",
152
169
  "TABLE_COMPUTE_REPORT_VERSION",
170
+ "UnsupportedDocumentFormat",
153
171
  "apply_style_profile_to_plan",
154
172
  "build_comparison_table_plan",
155
173
  "build_image_grid",
@@ -0,0 +1,573 @@
1
+ """본문(섹션 직속 문단) 바이트보존 op — Stage 2 결정표의 표-밖 실행 어휘.
2
+
3
+ KACE 투고본 변환 스파이크(본문 인용 [n] 치환 · 참고문헌 문단 재배열)의 스택 승격.
4
+ :func:`hwpx.table_patch.apply_table_ops` 와 동형 계약: 순차 적용, fail-closed
5
+ (정합 불가 시 이유와 함께 skip), op별 transcript, ``dry_run`` (해석·검증은 전부
6
+ 실제로 수행하되 파일은 쓰지 않음 — 상의 루프의 승인 근거).
7
+
8
+ Ops (index = 섹션 직속 ``<hp:p>`` 문서순, **op 실행 시점의 현재 상태** 기준):
9
+
10
+ - ``replace_text`` {find, replace, count=1}
11
+ ``<hp:t>`` 텍스트 콘텐츠 안에서만 매치(태그/속성 불가침). 발견 수가 count와
12
+ 다르면 refuse — 런 경계를 걸친 문자열은 매치되지 않아 자연히 refuse된다.
13
+ - ``delete_paragraph`` {index}
14
+ 표를 품은 문단은 refuse(표까지 무음 소실 방지, ``allow_tables=True``로 해제).
15
+ - ``insert_paragraph_by_clone`` {ref_index, count=1, texts=[...]?}
16
+ 참조 문단을 서식 verbatim 복제(id 재작성·linesegarray 제거) 후 ref 뒤에 삽입.
17
+ texts[i]가 있으면 해당 클론의 텍스트를 교체 — **이웃 문단 서식 상속** 경로.
18
+ - ``set_paragraph_text`` {index, text}
19
+ index번째 섹션 직속 문단의 텍스트를 통째 설정(fill_cell의 본문판) — 빈/마커
20
+ 문단("가." 등)에 내용을 넣을 때. 첫 런 서식 상속, stale linesegarray 제거.
21
+ - ``reorder_paragraphs`` {start, end, order}
22
+ 연속 구간 [start..end]를 order(구간 내 상대 인덱스 순열)로 재배열.
23
+ - ``restyle_text`` {find, count=1, text_color?, drop_italic=True}
24
+ find를 품은 런의 charPr을 정규화 변형(색 교체·이탤릭 제거)으로 재매핑 —
25
+ 안내용 슬롯 서식(파란 이탤릭 등)을 상속한 채움 텍스트를 "사람이 작성한
26
+ 본문"으로 만든다(2026-07-07 AI중점학교 신청서 실측). 원본 charPr은 불변,
27
+ 변형은 header에 새 id로 추가(dedupe).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import html
33
+ import re
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import Any, Mapping, Sequence
37
+
38
+ from .patch import (
39
+ _finalize,
40
+ _patch_zip_entries,
41
+ _read_source_bytes,
42
+ _rewrite_zip_entries,
43
+ _strip_paragraph_layout_cache,
44
+ _text_edit_for_paragraph,
45
+ )
46
+
47
+ __all__ = [
48
+ "BodyOpsResult", "apply_body_ops", "direct_paragraph_spans",
49
+ "strip_runs_by_color", "recolor_runs_by_color",
50
+ ]
51
+
52
+ _P_EDGE_RE = re.compile(r"<hp:p[ >]|</hp:p>")
53
+ _T_CONTENT_RE = re.compile(r"<hp:t(?:\s[^>]*)?>(.*?)</hp:t>", re.S)
54
+ _PARA_ID_RE = re.compile(r'(<hp:p\b[^>]*\bid=")(\d+)(")')
55
+ _TBL_RE = re.compile(r"<hp:tbl\b")
56
+
57
+
58
+ def direct_paragraph_spans(section_xml: str) -> list[tuple[int, int]]:
59
+ """섹션 직속 ``<hp:p>`` 블록들의 (start, end) — 중첩(셀 내부) 문단 제외."""
60
+ spans: list[tuple[int, int]] = []
61
+ depth, start = 0, -1
62
+ for m in _P_EDGE_RE.finditer(section_xml):
63
+ if m.group().startswith("</"):
64
+ depth -= 1
65
+ if depth == 0 and start >= 0:
66
+ spans.append((start, m.end()))
67
+ start = -1
68
+ else:
69
+ if depth == 0:
70
+ start = m.start()
71
+ depth += 1
72
+ return spans
73
+
74
+
75
+ def _all_paragraph_spans(section_xml: str) -> list[tuple[int, int]]:
76
+ """모든 깊이의 ``<hp:p>`` 블록 span (셀 내부 포함) — lineseg 무효화 대상 탐색용."""
77
+ spans: list[tuple[int, int]] = []
78
+ stack: list[int] = []
79
+ for m in _P_EDGE_RE.finditer(section_xml):
80
+ if m.group().startswith("</"):
81
+ if stack:
82
+ spans.append((stack.pop(), m.end()))
83
+ else:
84
+ stack.append(m.start())
85
+ return spans
86
+
87
+
88
+ def _preview(text: str, limit: int = 60) -> str:
89
+ flat = re.sub(r"\s+", " ", text).strip()
90
+ return flat[: limit - 1] + "…" if len(flat) > limit else flat
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class BodyOpsResult:
95
+ data: bytes
96
+ skipped: tuple[dict[str, Any], ...]
97
+ transcript: tuple[dict[str, Any], ...]
98
+ changed_parts: tuple[str, ...]
99
+ byte_identical: bool
100
+ open_safety: dict[str, Any]
101
+
102
+ @property
103
+ def ok(self) -> bool:
104
+ return bool(self.open_safety.get("ok")) and not self.skipped
105
+
106
+ def to_dict(self) -> dict[str, Any]:
107
+ return {
108
+ "ok": self.ok,
109
+ "skipped": list(self.skipped),
110
+ "transcript": list(self.transcript),
111
+ "changedParts": list(self.changed_parts),
112
+ "byteIdentical": self.byte_identical,
113
+ "openSafety": self.open_safety,
114
+ }
115
+
116
+
117
+ def _op_replace_text(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
118
+ find = str(op["find"])
119
+ replace = str(op.get("replace", ""))
120
+ expected = int(op.get("count", 1))
121
+ esc_find = html.escape(find, quote=False)
122
+ esc_replace = html.escape(replace, quote=False)
123
+ hits: list[tuple[int, int]] = []
124
+ for t in _T_CONTENT_RE.finditer(xml):
125
+ a, b = t.start(1), t.end(1)
126
+ pos = xml.find(esc_find, a)
127
+ while pos != -1 and pos + len(esc_find) <= b:
128
+ hits.append((pos, pos + len(esc_find)))
129
+ pos = xml.find(esc_find, pos + 1)
130
+ if pos >= b:
131
+ break
132
+ if len(hits) != expected:
133
+ raise ValueError(
134
+ f"replace_text: {find!r} matched {len(hits)} time(s) inside <hp:t> "
135
+ f"(expected {expected}) — run-spanning or ambiguous text is refused"
136
+ )
137
+ # 편집된 문단의 linesegarray(한컴 줄배치 캐시)를 함께 제거해야 한다 — 안 지우면
138
+ # 한컴이 옛 텍스트 기준 줄배치를 재사용해 긴 새 텍스트가 겹쳐 렌더된다
139
+ # (2026-07-07 AI중점학교 신청서 실측: 교체 문단만 줄겹침, lineseg 제거된
140
+ # 클론 문단은 정상). 문단 단위로 묶어 뒤에서부터: 치환 → 캐시 제거 → 재조립.
141
+ para_spans = _all_paragraph_spans(xml)
142
+
143
+ def _innermost(pos: int) -> tuple[int, int]:
144
+ best = None
145
+ for a, b in para_spans:
146
+ if a <= pos < b and (best is None or (a >= best[0] and b <= best[1])):
147
+ best = (a, b)
148
+ if best is None:
149
+ raise ValueError("replace_text: match outside any <hp:p> paragraph")
150
+ return best
151
+
152
+ by_para: dict[tuple[int, int], list[tuple[int, int]]] = {}
153
+ for hit in hits:
154
+ by_para.setdefault(_innermost(hit[0]), []).append(hit)
155
+ for (pa, pb), phits in sorted(by_para.items(), reverse=True):
156
+ block = xml[pa:pb]
157
+ for a, b in sorted(phits, reverse=True):
158
+ block = block[: a - pa] + esc_replace + block[b - pa:]
159
+ block = _strip_paragraph_layout_cache(block.encode("utf-8")).decode("utf-8")
160
+ xml = xml[:pa] + block + xml[pb:]
161
+ return xml, {"find": _preview(find), "replace": _preview(replace), "hits": expected}
162
+
163
+
164
+ def _op_delete_paragraph(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
165
+ spans = direct_paragraph_spans(xml)
166
+ index = int(op["index"])
167
+ if not 0 <= index < len(spans):
168
+ raise ValueError(f"delete_paragraph: index {index} out of range (0..{len(spans) - 1})")
169
+ a, b = spans[index]
170
+ block = xml[a:b]
171
+ if _TBL_RE.search(block) and not op.get("allow_tables"):
172
+ raise ValueError(
173
+ "delete_paragraph: paragraph wraps a table — refuse (allow_tables=True to override)"
174
+ )
175
+ old = "".join(m.group(1) for m in _T_CONTENT_RE.finditer(block))
176
+ return xml[:a] + xml[b:], {"index": index, "old": _preview(html.unescape(old))}
177
+
178
+
179
+ def _op_insert_paragraph_by_clone(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
180
+ spans = direct_paragraph_spans(xml)
181
+ ref = int(op["ref_index"])
182
+ count = int(op.get("count", 1))
183
+ texts = list(op.get("texts") or [])
184
+ if not 0 <= ref < len(spans):
185
+ raise ValueError(f"insert_paragraph_by_clone: ref_index {ref} out of range")
186
+ if count < 1:
187
+ raise ValueError("insert_paragraph_by_clone: count must be >= 1")
188
+ if texts and len(texts) != count:
189
+ raise ValueError("insert_paragraph_by_clone: len(texts) must equal count")
190
+ a, b = spans[ref]
191
+ block = xml[a:b]
192
+ if _TBL_RE.search(block):
193
+ raise ValueError("insert_paragraph_by_clone: ref paragraph wraps a table — refuse")
194
+ stripped = _strip_paragraph_layout_cache(block.encode("utf-8")).decode("utf-8")
195
+ clones: list[str] = []
196
+ for i in range(count):
197
+ clone = _PARA_ID_RE.sub(
198
+ lambda m: m.group(1) + str((int(m.group(2)) + (i + 1) * 100003) & 0x7FFFFFFF) + m.group(3),
199
+ stripped,
200
+ )
201
+ if texts:
202
+ edited = _text_edit_for_paragraph(clone.encode("utf-8"), str(texts[i]))
203
+ if edited is None:
204
+ raise ValueError("insert_paragraph_by_clone: ref paragraph has no text run to fill")
205
+ clone = edited[2].decode("utf-8")
206
+ clones.append(clone)
207
+ return xml[:b] + "".join(clones) + xml[b:], {
208
+ "refIndex": ref,
209
+ "count": count,
210
+ "texts": [_preview(str(t)) for t in texts],
211
+ }
212
+
213
+
214
+ def _op_reorder_paragraphs(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
215
+ spans = direct_paragraph_spans(xml)
216
+ start, end = int(op["start"]), int(op["end"])
217
+ order = [int(i) for i in op["order"]]
218
+ if not (0 <= start <= end < len(spans)):
219
+ raise ValueError(f"reorder_paragraphs: range {start}..{end} out of bounds")
220
+ size = end - start + 1
221
+ if sorted(order) != list(range(size)):
222
+ raise ValueError("reorder_paragraphs: order must be a permutation of 0..end-start")
223
+ region = spans[start:end + 1]
224
+ for (a0, b0), (a1, _b1) in zip(region, region[1:]):
225
+ if xml[b0:a1].strip():
226
+ raise ValueError("reorder_paragraphs: non-whitespace content between paragraphs — refuse")
227
+ blocks = [xml[a:b] for a, b in region]
228
+ new_region = "".join(blocks[i] for i in order)
229
+ return xml[: region[0][0]] + new_region + xml[region[-1][1]:], {
230
+ "start": start, "end": end, "order": order,
231
+ }
232
+
233
+
234
+ def _materialize_restyled_charpr(
235
+ header: str, base_id: str, text_color: str | None, drop_italic: bool,
236
+ cache: dict[tuple, str],
237
+ ) -> tuple[str, str]:
238
+ """charPr *base_id*를 색/이탤릭만 바꾼 변형으로 복제해 header에 추가."""
239
+ key = (base_id, text_color, drop_italic)
240
+ if key in cache:
241
+ return header, cache[key]
242
+ bid = re.escape(base_id)
243
+ m = re.search(r'<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?\bid="' + bid + r'".*?</(?:[A-Za-z_][\w.-]*:)?charPr>', header, re.DOTALL)
244
+ if m is None:
245
+ m = re.search(r'<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?\bid="' + bid + r'"[^>]*/>', header)
246
+ if m is None:
247
+ raise ValueError(f"restyle_text: base charPr {base_id} not found in header")
248
+ ids = [int(x) for x in re.findall(r'<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?\bid="(\d+)"', header)]
249
+ new_id = str(max(ids) + 1 if ids else 0)
250
+ clone = re.sub(r'(\bid=")\d+(")', r"\g<1>" + new_id + r"\g<2>", m.group(0), count=1)
251
+ if text_color is not None:
252
+ if re.search(r'\btextColor="[^"]*"', clone):
253
+ clone = re.sub(r'(\btextColor=")[^"]*(")', r"\g<1>" + text_color + r"\g<2>", clone, count=1)
254
+ else:
255
+ clone = re.sub(r'(<(?:[A-Za-z_][\w.-]*:)?charPr\b)', r"\g<1>" + f' textColor="{text_color}"', clone, count=1)
256
+ if drop_italic:
257
+ clone = re.sub(r'<(?:[A-Za-z_][\w.-]*:)?italic\b[^>]*/>', "", clone)
258
+ clone = re.sub(r'<((?:[A-Za-z_][\w.-]*:)?)italic\b[^>]*>.*?</\1italic>', "", clone, flags=re.DOTALL)
259
+ close = re.search(r'</(?:[A-Za-z_][\w.-]*:)?charProperties>', header)
260
+ if close is None:
261
+ raise ValueError("restyle_text: charProperties close tag not found")
262
+ header = header[: close.start()] + clone + header[close.start():]
263
+ header = re.sub(
264
+ r'(<(?:[A-Za-z_][\w.-]*:)?charProperties\b[^>]*?\bitemCnt=")(\d+)(")',
265
+ lambda mm: mm.group(1) + str(int(mm.group(2)) + 1) + mm.group(3),
266
+ header, count=1,
267
+ )
268
+ cache[key] = new_id
269
+ return header, new_id
270
+
271
+
272
+ def _op_restyle_text(xml: str, op: Mapping[str, Any], ctx: dict[str, Any]) -> tuple[str, dict[str, Any]]:
273
+ find = str(op["find"])
274
+ expected = int(op.get("count", 1))
275
+ text_color = op.get("text_color", op.get("textColor"))
276
+ drop_italic = bool(op.get("drop_italic", op.get("dropItalic", True)))
277
+ esc_find = html.escape(find, quote=False)
278
+ hits: list[int] = []
279
+ for t in _T_CONTENT_RE.finditer(xml):
280
+ a, b = t.start(1), t.end(1)
281
+ pos = xml.find(esc_find, a)
282
+ while pos != -1 and pos + len(esc_find) <= b:
283
+ hits.append(pos)
284
+ pos = xml.find(esc_find, pos + 1)
285
+ if pos >= b:
286
+ break
287
+ if len(hits) != expected:
288
+ raise ValueError(
289
+ f"restyle_text: {find!r} matched {len(hits)} time(s) inside <hp:t> (expected {expected})"
290
+ )
291
+ remapped = []
292
+ for pos in sorted(hits, reverse=True):
293
+ run_open = xml.rfind("<hp:run", 0, pos)
294
+ if run_open < 0:
295
+ raise ValueError("restyle_text: enclosing <hp:run> not found")
296
+ tag_end = xml.find(">", run_open)
297
+ open_tag = xml[run_open: tag_end + 1]
298
+ m = re.search(r'charPrIDRef="(\d+)"', open_tag)
299
+ if m is None:
300
+ raise ValueError("restyle_text: run has no charPrIDRef")
301
+ base = m.group(1)
302
+ ctx["header"], new_id = _materialize_restyled_charpr(
303
+ ctx["header"], base, text_color, drop_italic, ctx["charpr_cache"]
304
+ )
305
+ ctx["header_changed"] = True
306
+ new_tag = open_tag.replace(f'charPrIDRef="{base}"', f'charPrIDRef="{new_id}"', 1)
307
+ xml = xml[:run_open] + new_tag + xml[tag_end + 1:]
308
+ remapped.append(f"{base}→{new_id}")
309
+ return xml, {"find": _preview(find), "hits": expected, "charPr": remapped,
310
+ "textColor": text_color, "dropItalic": drop_italic}
311
+
312
+
313
+ def _op_set_paragraph_text(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
314
+ spans = direct_paragraph_spans(xml)
315
+ index = int(op["index"])
316
+ if not 0 <= index < len(spans):
317
+ raise ValueError(f"set_paragraph_text: index {index} out of range (0..{len(spans) - 1})")
318
+ a, b = spans[index]
319
+ block = xml[a:b]
320
+ if _TBL_RE.search(block):
321
+ raise ValueError("set_paragraph_text: paragraph wraps a table — refuse")
322
+ edited = _text_edit_for_paragraph(block.encode("utf-8"), str(op["text"]))
323
+ if edited is None:
324
+ raise ValueError("set_paragraph_text: paragraph has no text run to set")
325
+ new_block = _strip_paragraph_layout_cache(edited[2]).decode("utf-8")
326
+ old = "".join(m.group(1) for m in _T_CONTENT_RE.finditer(block))
327
+ return xml[:a] + new_block + xml[b:], {"index": index, "old": _preview(html.unescape(old)),
328
+ "new": _preview(str(op["text"]))}
329
+
330
+
331
+ _OPS = {
332
+ "replace_text": _op_replace_text,
333
+ "delete_paragraph": _op_delete_paragraph,
334
+ "insert_paragraph_by_clone": _op_insert_paragraph_by_clone,
335
+ "reorder_paragraphs": _op_reorder_paragraphs,
336
+ "set_paragraph_text": _op_set_paragraph_text,
337
+ "restyle_text": _op_restyle_text, # dispatch에서 ctx 전달로 특수 처리
338
+ }
339
+
340
+
341
+ def recolor_runs_by_color(
342
+ source: str | Path | bytes,
343
+ from_hexes: Sequence[str],
344
+ to_color: str,
345
+ *,
346
+ output_path: str | Path | None = None,
347
+ dry_run: bool = False,
348
+ ) -> BodyOpsResult:
349
+ """문서 전체(셀 포함)에서 지정 색(정확 hex)의 run을 to_color로 재색.
350
+
351
+ 양식이 "이 색 글씨는 과목별로 수정"(평가계획 파랑)이라 선언한 슬롯을 채우면
352
+ 슬롯 색을 상속하는데, 채운 내용은 사람 본문이므로 검정이어야 한다. from_hexes와
353
+ 정확히 일치하는 charPr을 to_color 변형으로 복제(header)하고 해당 run의
354
+ charPrIDRef만 재매핑한다(글꼴·크기 등 나머지 서식 보존). 디자인색 오염 방지를
355
+ 위해 계열이 아닌 **정확 hex** 매칭."""
356
+ source_bytes = _read_source_bytes(source)
357
+ from_set = {h.upper() for h in from_hexes}
358
+ import io, zipfile
359
+
360
+ with zipfile.ZipFile(io.BytesIO(source_bytes)) as z:
361
+ names = z.namelist()
362
+ header_name = next((n for n in names if n.endswith("header.xml")), None)
363
+ header_xml = z.read(header_name).decode("utf-8") if header_name else ""
364
+ sections = {n: z.read(n).decode("utf-8") for n in names if re.search(r"section\d+\.xml$", n)}
365
+
366
+ ids = set()
367
+ for cm in re.finditer(r"<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?>", header_xml):
368
+ idm = re.search(r'\bid="(\d+)"', cm.group(0))
369
+ colm = re.search(r'\btextColor="([^"]+)"', cm.group(0))
370
+ if idm and colm and colm.group(1).upper() in from_set:
371
+ ids.add(idm.group(1))
372
+
373
+ cache: dict[tuple, str] = {}
374
+ idmap: dict[str, str] = {}
375
+ for rid in sorted(ids, key=int):
376
+ header_xml, new_id = _materialize_restyled_charpr(header_xml, rid, to_color, False, cache)
377
+ idmap[rid] = new_id
378
+
379
+ transcript: list[dict[str, Any]] = []
380
+ changed: set[str] = set()
381
+ for sp, xml in sections.items():
382
+ n = [0]
383
+
384
+ def _remap(m: "re.Match") -> str:
385
+ old = m.group(1)
386
+ if old not in idmap:
387
+ return m.group(0)
388
+ n[0] += 1
389
+ return m.group(0).replace(f'charPrIDRef="{old}"', f'charPrIDRef="{idmap[old]}"', 1)
390
+
391
+ new_xml = re.sub(r'<hp:run\b[^>]*\bcharPrIDRef="(\d+)"[^>]*>', _remap, xml)
392
+ if n[0] == 0:
393
+ continue
394
+ sections[sp] = new_xml
395
+ changed.add(sp)
396
+ transcript.append({"op": "recolor_runs_by_color", "sectionPath": sp,
397
+ "runsRecolored": n[0], "toColor": to_color,
398
+ "status": "would_apply" if dry_run else "applied"})
399
+
400
+ intermediate = source_bytes
401
+ if changed and header_name:
402
+ payload = {sp: sections[sp].encode("utf-8") for sp in changed}
403
+ payload[header_name] = header_xml.encode("utf-8")
404
+ try:
405
+ intermediate = _patch_zip_entries(source_bytes, payload)
406
+ except ValueError:
407
+ intermediate = _rewrite_zip_entries(source_bytes, payload)
408
+ open_safety, _ = _finalize(intermediate, None if dry_run else output_path, source=source)
409
+ return BodyOpsResult(intermediate, (), tuple(transcript), tuple(sorted(changed)),
410
+ intermediate == source_bytes, open_safety)
411
+
412
+
413
+ def strip_runs_by_color(
414
+ source: str | Path | bytes,
415
+ hex_colors: Sequence[str],
416
+ *,
417
+ output_path: str | Path | None = None,
418
+ dry_run: bool = False,
419
+ ) -> BodyOpsResult:
420
+ """문서 전체(셀 내부 포함)에서 지정 색의 run 텍스트를 비운다(런 구조 유지).
421
+
422
+ 양식이 색 범례로 "이 색 글씨는 모두 삭제"를 선언한 경우(평가계획 빨강)의
423
+ 일괄 청소 — 안내문·범례가 셀·헤딩 표에 흩어진 red run이라 문단 op가 못 닿는
424
+ 문제를 해결한다. charPrIDRef가 header에서 target textColor를 가리키는 run의
425
+ ``<hp:t>`` 내용을 비우고, 그 run이 든 문단의 stale linesegarray를 제거한다.
426
+ 미변경 부분은 byte-identical."""
427
+ source_bytes = _read_source_bytes(source)
428
+ import io, zipfile
429
+
430
+ targets = {h.upper() for h in hex_colors}
431
+ with zipfile.ZipFile(io.BytesIO(source_bytes)) as z:
432
+ names = z.namelist()
433
+ header_name = next((n for n in names if n.endswith("header.xml")), None)
434
+ header_xml = z.read(header_name).decode("utf-8") if header_name else ""
435
+ sections = {n: z.read(n).decode("utf-8") for n in names if re.search(r"section\d+\.xml$", n)}
436
+
437
+ # 계열 매칭(잔존 게이트와 정렬): 대상 색의 _color_family에 드는 모든 charPr.
438
+ from .guidance_scan import _color_family
439
+ target_families = {_color_family(h) for h in targets}
440
+ ids = set()
441
+ for cm in re.finditer(r"<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?>", header_xml):
442
+ tag = cm.group(0)
443
+ idm = re.search(r'\bid="(\d+)"', tag)
444
+ colm = re.search(r'\btextColor="([^"]+)"', tag)
445
+ if not (idm and colm):
446
+ continue
447
+ col = colm.group(1).upper()
448
+ if col in targets or _color_family(col) in target_families:
449
+ ids.add(idm.group(1))
450
+ transcript: list[dict[str, Any]] = []
451
+ changed: set[str] = set()
452
+ total = 0
453
+ counter = {"n": 0}
454
+ run_re = re.compile(
455
+ r"(?P<open><hp:run\b[^>]*\bcharPrIDRef=\"(\d+)\"[^>]*>)(?P<body>.*?)(?P<close></hp:run>)",
456
+ re.S,
457
+ )
458
+
459
+ def _sub(m: "re.Match") -> str:
460
+ if m.group(2) not in ids:
461
+ return m.group(0)
462
+ body = re.sub(r"(<hp:t\b[^>]*>).*?(</hp:t>)", r"\1\2", m.group("body"), flags=re.S)
463
+ if body != m.group("body"):
464
+ counter["n"] += 1
465
+ return m.group("open") + body + m.group("close")
466
+
467
+ for sp, xml in sections.items():
468
+ counter["n"] = 0
469
+ new_xml = run_re.sub(_sub, xml)
470
+ n = counter["n"]
471
+ if n == 0:
472
+ continue
473
+ # 편집된 문단들의 linesegarray 제거(재계산)
474
+ new_xml = re.sub(
475
+ r"(<hp:p\b(?:(?!</hp:p>).)*?<hp:t\b[^>]*></hp:t>(?:(?!</hp:p>).)*?)"
476
+ r"<(?P<ns>(?:[A-Za-z_][\w.-]*:)?)linesegarray\b(?:[^>]*?/>|[^>]*>.*?</(?P=ns)linesegarray>)",
477
+ r"\1", new_xml, flags=re.S,
478
+ )
479
+ sections[sp] = new_xml
480
+ changed.add(sp)
481
+ total += n
482
+ transcript.append({"op": "strip_runs_by_color", "sectionPath": sp,
483
+ "runsBlanked": n, "status": "would_apply" if dry_run else "applied"})
484
+
485
+ intermediate = source_bytes
486
+ if changed:
487
+ payload = {sp: sections[sp].encode("utf-8") for sp in changed}
488
+ try:
489
+ intermediate = _patch_zip_entries(source_bytes, payload)
490
+ except ValueError:
491
+ intermediate = _rewrite_zip_entries(source_bytes, payload)
492
+ open_safety, _ = _finalize(intermediate, None if dry_run else output_path, source=source)
493
+ return BodyOpsResult(intermediate, (), tuple(transcript), tuple(sorted(changed)),
494
+ intermediate == source_bytes, open_safety)
495
+
496
+
497
+ def apply_body_ops(
498
+ source: str | Path | bytes,
499
+ ops: Sequence[Mapping[str, Any]],
500
+ *,
501
+ output_path: str | Path | None = None,
502
+ dry_run: bool = False,
503
+ ) -> BodyOpsResult:
504
+ """섹션 직속 문단 op들을 바이트보존으로 적용한다 (모듈 docstring 참조)."""
505
+ source_bytes = _read_source_bytes(source)
506
+ import io, zipfile
507
+
508
+ header_part: str | None = None
509
+ with zipfile.ZipFile(io.BytesIO(source_bytes)) as z:
510
+ sections = {
511
+ n: z.read(n).decode("utf-8")
512
+ for n in z.namelist()
513
+ if re.search(r"section\d+\.xml$", n)
514
+ }
515
+ header_part = next((n for n in z.namelist() if n.endswith("header.xml")), None)
516
+ header_xml = z.read(header_part).decode("utf-8") if header_part else ""
517
+
518
+ ctx: dict[str, Any] = {"header": header_xml, "header_changed": False, "charpr_cache": {}}
519
+ skipped: list[dict[str, Any]] = []
520
+ transcript: list[dict[str, Any]] = []
521
+ changed: set[str] = set()
522
+
523
+ for op in ops:
524
+ name = str(op.get("op"))
525
+ sp = str(op.get("section_path") or op.get("sectionPath") or "Contents/section0.xml")
526
+ entry: dict[str, Any] = {"op": name, "sectionPath": sp}
527
+ handler = _OPS.get(name)
528
+ if handler is None:
529
+ entry["status"] = f"refused: unknown op {name!r}"
530
+ skipped.append(dict(entry))
531
+ transcript.append(entry)
532
+ continue
533
+ xml = sections.get(sp)
534
+ if xml is None:
535
+ entry["status"] = "refused: section part not found"
536
+ skipped.append(dict(entry))
537
+ transcript.append(entry)
538
+ continue
539
+ try:
540
+ if name == "restyle_text":
541
+ new_xml, detail = _op_restyle_text(xml, op, ctx)
542
+ else:
543
+ new_xml, detail = handler(xml, op)
544
+ except (ValueError, KeyError, TypeError) as exc:
545
+ entry["status"] = f"refused: {exc}"
546
+ skipped.append(dict(entry))
547
+ transcript.append(entry)
548
+ continue
549
+ sections[sp] = new_xml
550
+ changed.add(sp)
551
+ entry.update(detail)
552
+ entry["status"] = "would_apply" if dry_run else "applied"
553
+ transcript.append(entry)
554
+
555
+ intermediate = source_bytes
556
+ if changed or ctx["header_changed"]:
557
+ payload = {sp: sections[sp].encode("utf-8") for sp in changed}
558
+ if ctx["header_changed"] and header_part:
559
+ payload[header_part] = ctx["header"].encode("utf-8")
560
+ try:
561
+ intermediate = _patch_zip_entries(source_bytes, payload)
562
+ except ValueError:
563
+ intermediate = _rewrite_zip_entries(source_bytes, payload)
564
+
565
+ open_safety, _ = _finalize(intermediate, None if dry_run else output_path, source=source)
566
+ return BodyOpsResult(
567
+ intermediate,
568
+ tuple(skipped),
569
+ tuple(transcript),
570
+ tuple(sorted(changed)),
571
+ intermediate == source_bytes,
572
+ open_safety,
573
+ )