python-hwpx 3.4.1__tar.gz → 3.6.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 (358) hide show
  1. {python_hwpx-3.4.1/src/python_hwpx.egg-info → python_hwpx-3.6.0}/PKG-INFO +2 -2
  2. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/README.md +1 -1
  3. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/pyproject.toml +1 -1
  4. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/form_fit/engine.py +268 -3
  5. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/form_fit/measure.py +207 -0
  6. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/opc/package.py +59 -0
  7. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/document_parts.py +8 -4
  8. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/paragraph.py +3 -0
  9. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/run.py +4 -0
  10. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/table.py +11 -0
  11. {python_hwpx-3.4.1 → python_hwpx-3.6.0/src/python_hwpx.egg-info}/PKG-INFO +2 -2
  12. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/python_hwpx.egg-info/SOURCES.txt +1 -0
  13. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_document_formatting.py +9 -2
  14. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_document_save_api.py +4 -1
  15. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_form_fit.py +187 -0
  16. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_form_fit_integration.py +15 -0
  17. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_kordoc_absorption.py +8 -6
  18. python_hwpx-3.6.0/tests/test_layout_cache_scope.py +122 -0
  19. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_opc_package.py +38 -6
  20. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/LICENSE +0 -0
  21. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/NOTICE +0 -0
  22. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/setup.cfg +0 -0
  23. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/__init__.py +0 -0
  24. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/_document/__init__.py +0 -0
  25. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/_document/_units.py +0 -0
  26. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/_document/fields.py +0 -0
  27. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/_document/layout.py +0 -0
  28. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/_document/media.py +0 -0
  29. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/_document/memos.py +0 -0
  30. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/_document/persistence.py +0 -0
  31. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/_document/shapes.py +0 -0
  32. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/_document/tracked.py +0 -0
  33. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/__init__.py +0 -0
  34. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/blueprint/__init__.py +0 -0
  35. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/blueprint/bundle.py +0 -0
  36. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/blueprint/catalog.py +0 -0
  37. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/blueprint/dump.py +0 -0
  38. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/blueprint/mapping.py +0 -0
  39. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/blueprint/model.py +0 -0
  40. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/blueprint/native.py +0 -0
  41. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/blueprint/replay.py +0 -0
  42. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/catalog.py +0 -0
  43. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/cli.py +0 -0
  44. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/commands.py +0 -0
  45. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/document.py +0 -0
  46. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/form_plan.py +0 -0
  47. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/model.py +0 -0
  48. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/path.py +0 -0
  49. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/query.py +0 -0
  50. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/agent/story.py +0 -0
  51. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/authoring.py +0 -0
  52. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/benchmark/__init__.py +0 -0
  53. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/benchmark/blind_eval.py +0 -0
  54. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/body_patch.py +0 -0
  55. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/builder/__init__.py +0 -0
  56. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/builder/core.py +0 -0
  57. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/builder/report.py +0 -0
  58. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/conformance/__init__.py +0 -0
  59. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/conformance/badges.py +0 -0
  60. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/conformance/corpus/corpus.json +0 -0
  61. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/conformance/corpus/meeting_summary.hwpx +0 -0
  62. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/conformance/corpus/notice.hwpx +0 -0
  63. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/conformance/corpus/report_table.hwpx +0 -0
  64. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/conformance/corpus.py +0 -0
  65. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/conformance/report.py +0 -0
  66. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/conformance/roundtrip_batch.py +0 -0
  67. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/conformance/runner.py +0 -0
  68. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/data/Skeleton.hwpx +0 -0
  69. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/__init__.py +0 -0
  70. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/_support.py +0 -0
  71. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/composer.py +0 -0
  72. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/harvest.py +0 -0
  73. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/plan.py +0 -0
  74. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profile.py +0 -0
  75. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/application_form/fragments/body.xml +0 -0
  76. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/application_form/fragments/heading.xml +0 -0
  77. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/application_form/fragments/info_table.xml +0 -0
  78. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/application_form/fragments/title.xml +0 -0
  79. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/application_form/profile.json +0 -0
  80. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/application_form/template.hwpx +0 -0
  81. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/home_notice/fragments/body.xml +0 -0
  82. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/home_notice/fragments/heading.xml +0 -0
  83. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/home_notice/fragments/title.xml +0 -0
  84. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/home_notice/profile.json +0 -0
  85. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/home_notice/template.hwpx +0 -0
  86. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/official_notice/fragments/body.xml +0 -0
  87. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/official_notice/fragments/heading.xml +0 -0
  88. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/official_notice/fragments/info_table.xml +0 -0
  89. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/official_notice/fragments/title.xml +0 -0
  90. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/official_notice/profile.json +0 -0
  91. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/official_notice/template.hwpx +0 -0
  92. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/report/fragments/body.xml +0 -0
  93. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/report/fragments/heading.xml +0 -0
  94. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/report/fragments/info_table.xml +0 -0
  95. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/report/fragments/title.xml +0 -0
  96. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/report/profile.json +0 -0
  97. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/profiles/report/template.hwpx +0 -0
  98. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/design/validator.py +0 -0
  99. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/document.py +0 -0
  100. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/evalplan_fill.py +0 -0
  101. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/exam/__init__.py +0 -0
  102. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/exam/compose.py +0 -0
  103. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/exam/ir.py +0 -0
  104. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/exam/measure.py +0 -0
  105. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/exam/parser.py +0 -0
  106. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/exam/profile.py +0 -0
  107. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/fill_residue.py +0 -0
  108. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/form_fill.py +0 -0
  109. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/form_fit/__init__.py +0 -0
  110. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/form_fit/apply.py +0 -0
  111. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/form_fit/policy.py +0 -0
  112. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/form_fit/report.py +0 -0
  113. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/form_fit/seal.py +0 -0
  114. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/form_fit/wordbox.py +0 -0
  115. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/formfill_quality.py +0 -0
  116. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/guidance_scan.py +0 -0
  117. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/ingest/__init__.py +0 -0
  118. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/ingest/base.py +0 -0
  119. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/ingest/hwpx_converter.py +0 -0
  120. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/layout/__init__.py +0 -0
  121. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/layout/lint.py +0 -0
  122. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/layout/report.py +0 -0
  123. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/opc/relationships.py +0 -0
  124. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/opc/security.py +0 -0
  125. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/opc/xml_utils.py +0 -0
  126. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/__init__.py +0 -0
  127. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/_document_impl.py +0 -0
  128. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/_document_primitives.py +0 -0
  129. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/body.py +0 -0
  130. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/canonical_defaults.py +0 -0
  131. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/common.py +0 -0
  132. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/document.py +0 -0
  133. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/header.py +0 -0
  134. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/header_part.py +0 -0
  135. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/memo.py +0 -0
  136. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/namespaces.py +0 -0
  137. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/numbering.py +0 -0
  138. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/objects.py +0 -0
  139. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/parser.py +0 -0
  140. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/schema.py +0 -0
  141. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/section.py +0 -0
  142. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/section_format.py +0 -0
  143. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/section_story.py +0 -0
  144. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/simple_parts.py +0 -0
  145. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/oxml/utils.py +0 -0
  146. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/package.py +0 -0
  147. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/patch.py +0 -0
  148. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/presets/__init__.py +0 -0
  149. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/presets/proposal.py +0 -0
  150. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/py.typed +0 -0
  151. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/quality/__init__.py +0 -0
  152. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/quality/ledger.py +0 -0
  153. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/quality/policy.py +0 -0
  154. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/quality/report.py +0 -0
  155. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/quality/save_pipeline.py +0 -0
  156. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/table_patch.py +0 -0
  157. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/template_formfit.py +0 -0
  158. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/templates.py +0 -0
  159. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/__init__.py +0 -0
  160. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/_schemas/header.xsd +0 -0
  161. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/_schemas/section.xsd +0 -0
  162. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/advanced_generators.py +0 -0
  163. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/archive_cli.py +0 -0
  164. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/doc_diff.py +0 -0
  165. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/exporter.py +0 -0
  166. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/fuzz/__init__.py +0 -0
  167. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/fuzz/__main__.py +0 -0
  168. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/fuzz/catalog.py +0 -0
  169. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/fuzz/generator.py +0 -0
  170. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/fuzz/minimize.py +0 -0
  171. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/fuzz/runner.py +0 -0
  172. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/generic_inventory.py +0 -0
  173. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/id_integrity.py +0 -0
  174. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/idempotence.py +0 -0
  175. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/ir_equality.py +0 -0
  176. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/layout_preview.py +0 -0
  177. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/mail_merge.py +0 -0
  178. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/markdown_export.py +0 -0
  179. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/object_finder.py +0 -0
  180. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/official_lint.py +0 -0
  181. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/package_reconcile.py +0 -0
  182. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/package_validator.py +0 -0
  183. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/page_guard.py +0 -0
  184. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/pii.py +0 -0
  185. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/read_fidelity.py +0 -0
  186. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/recover.py +0 -0
  187. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/redline.py +0 -0
  188. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/repair.py +0 -0
  189. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/report_parser.py +0 -0
  190. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/report_utils.py +0 -0
  191. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/roundtrip_diff.py +0 -0
  192. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/style_profile.py +0 -0
  193. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/table_cleanup.py +0 -0
  194. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/table_compute.py +0 -0
  195. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/table_navigation.py +0 -0
  196. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/template_analyzer.py +0 -0
  197. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/text_extract_cli.py +0 -0
  198. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/text_extractor.py +0 -0
  199. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/toc_author.py +0 -0
  200. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/toc_fidelity.py +0 -0
  201. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/tools/validator.py +0 -0
  202. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/__init__.py +0 -0
  203. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/_hancom_open_rate.ps1 +0 -0
  204. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/_refresh_hwpx_mac.applescript +0 -0
  205. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/_render_hwpx.ps1 +0 -0
  206. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/_render_hwpx_mac.applescript +0 -0
  207. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/detectors.py +0 -0
  208. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/diff.py +0 -0
  209. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/fixture_corpus.py +0 -0
  210. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/hancom_worker.py +0 -0
  211. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/masks.py +0 -0
  212. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/oracle.py +0 -0
  213. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/page_qa.py +0 -0
  214. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/qa_contracts.py +0 -0
  215. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/qa_metrics.py +0 -0
  216. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/hwpx/visual/report.py +0 -0
  217. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
  218. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/python_hwpx.egg-info/entry_points.txt +0 -0
  219. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/python_hwpx.egg-info/requires.txt +0 -0
  220. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/src/python_hwpx.egg-info/top_level.txt +0 -0
  221. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_advanced_generators.py +0 -0
  222. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_agent_blueprint_contracts.py +0 -0
  223. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_agent_blueprint_dump.py +0 -0
  224. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_agent_blueprint_replay.py +0 -0
  225. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_agent_catalog.py +0 -0
  226. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_agent_cli.py +0 -0
  227. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_agent_commands.py +0 -0
  228. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_agent_contracts.py +0 -0
  229. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_agent_document.py +0 -0
  230. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_agent_mixed_form.py +0 -0
  231. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_agent_path_query.py +0 -0
  232. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_agent_story_commands.py +0 -0
  233. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_authoring_native_toc.py +0 -0
  234. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_authoring_profile_routing.py +0 -0
  235. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_authoring_profile_routing_oracle.py +0 -0
  236. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_authoring_quality_corpus.py +0 -0
  237. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_authoring_render_check.py +0 -0
  238. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_blind_eval_fixture.py +0 -0
  239. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_body_patch.py +0 -0
  240. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_builder_core.py +0 -0
  241. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_builder_plan_v2.py +0 -0
  242. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_builder_vertical_slice.py +0 -0
  243. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_byte_patch_identity.py +0 -0
  244. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_cell_line_spacing.py +0 -0
  245. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_comment_node_robustness.py +0 -0
  246. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_conformance.py +0 -0
  247. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_corpus_read_fidelity.py +0 -0
  248. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_corpus_toc_verify.py +0 -0
  249. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_coverage_promotion.py +0 -0
  250. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_coverage_targets.py +0 -0
  251. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_design_builder.py +0 -0
  252. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_deviations_registry.py +0 -0
  253. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_doc_diff.py +0 -0
  254. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_document_context_manager.py +0 -0
  255. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_document_facade_surface.py +0 -0
  256. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_document_plan.py +0 -0
  257. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_document_plan_computed_fields.py +0 -0
  258. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_evalplan_fill.py +0 -0
  259. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_exam_compose.py +0 -0
  260. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_exam_compose_oracle.py +0 -0
  261. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_exam_fixtures.py +0 -0
  262. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_exam_ir.py +0 -0
  263. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_exam_measure.py +0 -0
  264. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_exam_parser.py +0 -0
  265. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_exam_profile.py +0 -0
  266. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_existing_document_format_editing.py +0 -0
  267. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_fill_residue.py +0 -0
  268. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_form_fields.py +0 -0
  269. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_form_fill_split_run.py +0 -0
  270. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_form_fit_seal.py +0 -0
  271. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_form_fit_seal_placement.py +0 -0
  272. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_form_fit_wordbox.py +0 -0
  273. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_formfill_differential_driver.py +0 -0
  274. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_formfill_quality.py +0 -0
  275. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_fuzz_loop.py +0 -0
  276. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_fuzz_regressions.py +0 -0
  277. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_gap_closure_tools.py +0 -0
  278. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_government_report_preset.py +0 -0
  279. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_government_table_profile.py +0 -0
  280. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_guidance_scan.py +0 -0
  281. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_hancom_render_worker.py +0 -0
  282. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_hp_tab_support.py +0 -0
  283. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_hwpxlib_corpus_read.py +0 -0
  284. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_id_generator_range.py +0 -0
  285. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_id_integrity.py +0 -0
  286. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_image_object_workflow.py +0 -0
  287. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_ingest.py +0 -0
  288. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_inline_models.py +0 -0
  289. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_integration_hwpx_compatibility.py +0 -0
  290. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_integration_roundtrip.py +0 -0
  291. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_layout_lint.py +0 -0
  292. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_layout_preview.py +0 -0
  293. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_m9_p0_spike_tools.py +0 -0
  294. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_mail_merge_fit.py +0 -0
  295. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_mail_merge_table_compute.py +0 -0
  296. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_mail_merge_xlsx.py +0 -0
  297. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_markdown_export.py +0 -0
  298. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_memo_and_style_editing.py +0 -0
  299. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_mixed_form_plan.py +0 -0
  300. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_namespace_handling.py +0 -0
  301. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_new_features.py +0 -0
  302. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_official_document_style.py +0 -0
  303. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_official_lint_gongmun_gate.py +0 -0
  304. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_official_lint_tableaware.py +0 -0
  305. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_open_safety_corpus.py +0 -0
  306. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_openrate.py +0 -0
  307. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_oxml_modularization.py +0 -0
  308. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_oxml_parsing.py +0 -0
  309. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_packaging_license_metadata.py +0 -0
  310. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_packaging_py_typed.py +0 -0
  311. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_paragraph_keep_together.py +0 -0
  312. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_paragraph_section_management.py +0 -0
  313. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_pii.py +0 -0
  314. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_pii_leak_sweep.py +0 -0
  315. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_pii_structural.py +0 -0
  316. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_pii_wiring.py +0 -0
  317. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_proposal_preset.py +0 -0
  318. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_public_runtime_boundary.py +0 -0
  319. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_question_split_detector.py +0 -0
  320. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_read_fidelity.py +0 -0
  321. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_recover_broken_zip.py +0 -0
  322. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_redline_authoring.py +0 -0
  323. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_redline_verify.py +0 -0
  324. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_repair_repack.py +0 -0
  325. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_report_parser.py +0 -0
  326. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_report_utils.py +0 -0
  327. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_repr_snapshots.py +0 -0
  328. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_rhwp_t1_gates.py +0 -0
  329. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_rhwp_t2_verification.py +0 -0
  330. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_roundtrip_fidelity.py +0 -0
  331. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_row_heights.py +0 -0
  332. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_save_pipeline.py +0 -0
  333. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_save_pipeline_no_bypass.py +0 -0
  334. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_section_headers.py +0 -0
  335. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_set_paragraph_format_keep.py +0 -0
  336. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_skeleton_template_ids.py +0 -0
  337. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_split_cell.py +0 -0
  338. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_split_merged_cell.py +0 -0
  339. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_style_profile.py +0 -0
  340. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_table_cleanup.py +0 -0
  341. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_table_navigation.py +0 -0
  342. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_table_patch.py +0 -0
  343. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_table_patch_dryrun.py +0 -0
  344. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_table_patch_m105.py +0 -0
  345. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_tables_default_border.py +0 -0
  346. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_template_analyzer_enrichment.py +0 -0
  347. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_template_formfit.py +0 -0
  348. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_text_extractor_annotations.py +0 -0
  349. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_toc_author.py +0 -0
  350. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_toc_fidelity.py +0 -0
  351. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_validation_severity.py +0 -0
  352. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_validator_comment_nodes.py +0 -0
  353. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_version_metadata.py +0 -0
  354. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_visual_fixture_corpus.py +0 -0
  355. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_visual_oracle.py +0 -0
  356. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_visual_oracle_budget.py +0 -0
  357. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_visual_qa_contracts.py +0 -0
  358. {python_hwpx-3.4.1 → python_hwpx-3.6.0}/tests/test_visual_qa_metrics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 3.4.1
3
+ Version: 3.6.0
4
4
  Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리
5
5
  Author: python-hwpx Maintainers
6
6
  License-Expression: Apache-2.0
@@ -70,7 +70,7 @@ Dynamic: license-file
70
70
  `hwpx-mcp-server`와 `hwpx-plugin`은 같은 프로젝트가 직접 유지보수하는 first-party 연동 구성요소입니다.
71
71
  “first-party”는 프로젝트 유지보수 관계를 뜻하며, 한컴 또는 제3자의 공식 인증을 뜻하지 않습니다.
72
72
 
73
- 현재 PyPI 공개 릴리스는 `python-hwpx 3.4.1`입니다. 일반
73
+ 현재 PyPI 공개 릴리스는 `python-hwpx 3.6.0`입니다. 일반
74
74
  `pip install python-hwpx`로 이 릴리스를 설치할 수 있습니다.
75
75
  현재 패키지 분류는 `Development Status :: 3 - Alpha`입니다. 이 분류는 API와 제품의
76
76
  성숙도를 나타내며, 공개 버전이나 플러그인의 최소 호환 버전을 대신하지 않습니다.
@@ -25,7 +25,7 @@
25
25
  `hwpx-mcp-server`와 `hwpx-plugin`은 같은 프로젝트가 직접 유지보수하는 first-party 연동 구성요소입니다.
26
26
  “first-party”는 프로젝트 유지보수 관계를 뜻하며, 한컴 또는 제3자의 공식 인증을 뜻하지 않습니다.
27
27
 
28
- 현재 PyPI 공개 릴리스는 `python-hwpx 3.4.1`입니다. 일반
28
+ 현재 PyPI 공개 릴리스는 `python-hwpx 3.6.0`입니다. 일반
29
29
  `pip install python-hwpx`로 이 릴리스를 설치할 수 있습니다.
30
30
  현재 패키지 분류는 `Development Status :: 3 - Alpha`입니다. 이 분류는 API와 제품의
31
31
  성숙도를 나타내며, 공개 버전이나 플러그인의 최소 호환 버전을 대신하지 않습니다.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-hwpx"
7
- version = "3.4.1"
7
+ version = "3.6.0"
8
8
  description = "한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = "Apache-2.0"
@@ -17,13 +17,32 @@ from __future__ import annotations
17
17
 
18
18
  from dataclasses import dataclass, replace
19
19
 
20
- from .measure import SlotMetrics, estimate_text_width, measure
20
+ from .measure import (
21
+ GROSS_ROW_GROWTH_FACTOR,
22
+ MIN_ROW_GROWTH_LINES,
23
+ SlotMetrics,
24
+ estimate_text_width,
25
+ measure,
26
+ )
21
27
  from .policy import FitPolicy
22
28
  from .report import FitResult
23
29
 
24
30
  # Font ladder step (points) used when shrinking.
25
31
  _SHRINK_STEP_PT = 0.5
26
32
 
33
+ # The vertical room could not be measured (merged cell, no height, or an auto-grow
34
+ # floor); the fit is width-only and the oracle must confirm the vertical fit.
35
+ _HEIGHT_UNAVAILABLE_NOTE = (
36
+ "cell height is not a usable budget (merged/auto-grow); fit is width-only — "
37
+ "render oracle should confirm the vertical fit"
38
+ )
39
+ # The content needs more vertical room than the authored cell height, but not
40
+ # grossly — the row will grow modestly. Reported (never silent), deferred to oracle.
41
+ _ROW_GROWTH_NOTE = (
42
+ "value needs more lines than the cell height budgets; the row will grow — "
43
+ "render oracle should confirm no page shift"
44
+ )
45
+
27
46
 
28
47
  @dataclass(slots=True)
29
48
  class FitEngine:
@@ -36,6 +55,26 @@ class FitEngine:
36
55
  policy: FitPolicy | None = None,
37
56
  *,
38
57
  field_id: str | None = None,
58
+ ) -> FitResult:
59
+ result = self._fit_decide(value, slot, policy, field_id=field_id)
60
+ inline_count = int(getattr(slot, "inline_object_count", 0) or 0)
61
+ if inline_count and (value or "") != "":
62
+ inline_width = float(getattr(slot, "inline_object_width", 0.0) or 0.0)
63
+ result.warnings.append(
64
+ f"cell line shared with {inline_count} inline control(s) "
65
+ f"({inline_width:.0f} HWPUNIT deducted from the width budget); "
66
+ "control-sharing cells are unusual fill targets — verify with a "
67
+ "render gate or pick the intended value cell"
68
+ )
69
+ return result
70
+
71
+ def _fit_decide(
72
+ self,
73
+ value: str,
74
+ slot: SlotMetrics,
75
+ policy: FitPolicy | None = None,
76
+ *,
77
+ field_id: str | None = None,
39
78
  ) -> FitResult:
40
79
  policy = policy or FitPolicy()
41
80
  value = "" if value is None else str(value)
@@ -58,12 +97,35 @@ class FitEngine:
58
97
  if policy.mode == "expand_row":
59
98
  return self._expand_row(value, slot, policy, field_id)
60
99
 
100
+ # Inline objects can consume the whole line; with less than a glyph of
101
+ # width left there is nothing to reshape into — refuse with a message
102
+ # that names the cause instead of an absurd wrapped-line estimate.
103
+ if (
104
+ int(getattr(slot, "inline_object_count", 0) or 0)
105
+ and slot.available_width < slot.font_pt * 100.0 * 0.6
106
+ ):
107
+ return FitResult(
108
+ ok=False,
109
+ value=value,
110
+ applied_value=value,
111
+ lines=0,
112
+ font_pt=slot.font_pt,
113
+ overflow_detected=True,
114
+ confidence="high",
115
+ errors=[
116
+ "FIELD_OVERFLOW: inline control(s) leave no usable width in "
117
+ "this cell; choose the intended value cell or allow row "
118
+ "expansion"
119
+ ],
120
+ field_id=field_id,
121
+ )
122
+
61
123
  # Reshaping ladder: wrap, shrink, or wrap_then_shrink, then fail_on_overflow.
62
124
  wrap_lines = policy.effective_max_lines if policy.may_wrap else 1
63
125
  wrap_slot = replace(slot, max_lines=wrap_lines)
64
126
  measurement = measure(value, wrap_slot)
65
127
  if measurement.fits:
66
- return FitResult(
128
+ result = FitResult(
67
129
  ok=True,
68
130
  value=value,
69
131
  applied_value=value,
@@ -78,11 +140,15 @@ class FitEngine:
78
140
  warnings=list(measurement.notes),
79
141
  field_id=field_id,
80
142
  )
143
+ return self._apply_height_budget(result, value, slot, policy, wrap_lines, field_id)
81
144
 
82
145
  if policy.may_shrink:
83
146
  shrunk = self._try_shrink(value, slot, policy, wrap_lines)
84
147
  if shrunk is not None:
85
- return replace(shrunk, field_id=field_id)
148
+ shrunk = replace(shrunk, field_id=field_id)
149
+ return self._apply_height_budget(
150
+ shrunk, value, slot, policy, wrap_lines, field_id
151
+ )
86
152
 
87
153
  # Still overflowing → terminal overflow action (confidence-gated).
88
154
  return self._overflow(value, wrap_slot, policy, field_id, measurement)
@@ -212,6 +278,205 @@ class FitEngine:
212
278
  candidate = round(candidate - _SHRINK_STEP_PT, 4)
213
279
  return best
214
280
 
281
+ # -- vertical (row-height) budget ------------------------------------- #
282
+ def _apply_height_budget(
283
+ self,
284
+ result: FitResult,
285
+ value: str,
286
+ slot: SlotMetrics,
287
+ policy: FitPolicy,
288
+ wrap_lines: int,
289
+ field_id: str | None,
290
+ ) -> FitResult:
291
+ """Overlay the authored row-height budget on a width-fitting *result*.
292
+
293
+ The width ladder decides horizontal fit; this decides whether the value
294
+ also sits inside the cell's *vertical* room, so a filled row does not
295
+ silently grow and shift the page (the dominant M9 form-fill defect).
296
+
297
+ Honesty contract (mirrors the width path, grounded in the S-085 P0/P1
298
+ measurement): the authored ``cellSz.height`` is an unreliable proxy in wild
299
+ forms (auto-grow, tiny floors, merges), so a *modest* vertical overflow is
300
+ reported and deferred to the render oracle; only a *gross* balloon shrinks
301
+ or fails closed. ``allow_row_expand`` / ``expand_row`` opt out entirely.
302
+ """
303
+
304
+ if slot.available_height is None:
305
+ if slot.height_unavailable and result.ok:
306
+ result.warnings.append(_HEIGHT_UNAVAILABLE_NOTE)
307
+ return result
308
+ if policy.allow_row_expand or policy.mode == "expand_row":
309
+ return result
310
+
311
+ font = result.font_pt if result.font_pt is not None else slot.font_pt
312
+ wrapped = result.lines if result.lines is not None else 1
313
+ verdict = self._height_verdict(slot, wrapped, font)
314
+ if verdict == "fits":
315
+ return result
316
+ if verdict == "modest":
317
+ # S-085 P1 calibration round 2 (differential-measured): the modest
318
+ # band is where pages actually shift, so try to shrink INTO the
319
+ # budget first; only defer to the oracle when no font >= min can
320
+ # land the value fully inside the authored height.
321
+ if policy.may_shrink:
322
+ shrunk = self._try_shrink_for_height(
323
+ value, slot, policy, wrap_lines, field_id, accept_modest=False
324
+ )
325
+ if shrunk is not None:
326
+ return shrunk
327
+ result.overflow_detected = True
328
+ result.warnings.append(_ROW_GROWTH_NOTE)
329
+ return result
330
+
331
+ # Gross vertical balloon: shrink (prefer a full fit, else the least
332
+ # growth), else fail-closed.
333
+ if policy.may_shrink:
334
+ shrunk = self._try_shrink_for_height(
335
+ value, slot, policy, wrap_lines, field_id, accept_modest=True
336
+ )
337
+ if shrunk is not None:
338
+ return shrunk
339
+ return self._overflow_height(value, slot, policy, field_id, wrapped, font)
340
+
341
+ def _height_verdict(self, slot: SlotMetrics, wrapped_lines: int, font_pt: float) -> str:
342
+ """Classify vertical fit as ``fits`` / ``modest`` / ``gross``."""
343
+
344
+ budget = slot.height_lines(font_pt)
345
+ if budget is None or wrapped_lines <= budget:
346
+ return "fits"
347
+ optimistic = slot.height_lines_optimistic(font_pt) or budget
348
+ if (
349
+ wrapped_lines > optimistic * GROSS_ROW_GROWTH_FACTOR
350
+ and (wrapped_lines - optimistic) >= MIN_ROW_GROWTH_LINES
351
+ ):
352
+ return "gross"
353
+ return "modest"
354
+
355
+ def _try_shrink_for_height(
356
+ self,
357
+ value: str,
358
+ slot: SlotMetrics,
359
+ policy: FitPolicy,
360
+ wrap_lines: int,
361
+ field_id: str | None,
362
+ *,
363
+ accept_modest: bool,
364
+ ) -> FitResult | None:
365
+ """Largest font (>= min) that lands the value INSIDE the height budget.
366
+
367
+ Walks the shrink ladder preferring a full vertical fit ("fits"). When
368
+ ``accept_modest`` is true and no font achieves a full fit, the largest
369
+ candidate whose growth is merely *modest* (never gross) is returned as
370
+ the least-damage fallback, reported and deferred to the oracle.
371
+ """
372
+
373
+ ceiling = policy.max_font_pt or slot.font_pt
374
+ candidate = min(slot.font_pt, ceiling)
375
+ modest_candidate: tuple[float, int] | None = None
376
+ while candidate >= policy.min_font_pt - 1e-9:
377
+ trial = replace(slot, font_pt=candidate, max_lines=wrap_lines)
378
+ m = measure(value, trial)
379
+ if m.fits and m.confidence == "high":
380
+ verdict = self._height_verdict(slot, m.lines, candidate)
381
+ if verdict == "fits":
382
+ return self._shrunk_result(
383
+ value, slot, candidate, m.lines, wrap_lines, field_id,
384
+ modest=False,
385
+ )
386
+ if verdict == "modest" and modest_candidate is None:
387
+ modest_candidate = (candidate, m.lines)
388
+ candidate = round(candidate - _SHRINK_STEP_PT, 4)
389
+ if accept_modest and modest_candidate is not None:
390
+ font_pt, lines = modest_candidate
391
+ return self._shrunk_result(
392
+ value, slot, font_pt, lines, wrap_lines, field_id, modest=True
393
+ )
394
+ return None
395
+
396
+ def _shrunk_result(
397
+ self,
398
+ value: str,
399
+ slot: SlotMetrics,
400
+ candidate: float,
401
+ lines: int,
402
+ wrap_lines: int,
403
+ field_id: str | None,
404
+ *,
405
+ modest: bool,
406
+ ) -> FitResult:
407
+ changes: dict[str, object] = {}
408
+ warnings: list[str] = []
409
+ if candidate != slot.font_pt:
410
+ changes = {
411
+ "font_pt": round(candidate, 2),
412
+ "from_font_pt": round(slot.font_pt, 2),
413
+ }
414
+ warnings.append(
415
+ f"font shrunk {slot.font_pt:g}pt → {candidate:g}pt to fit the row height"
416
+ )
417
+ if wrap_lines > 1 and lines > 1:
418
+ changes["wrapped_lines"] = lines
419
+ if modest:
420
+ warnings.append(_ROW_GROWTH_NOTE)
421
+ return FitResult(
422
+ ok=True,
423
+ value=value,
424
+ applied_value=value,
425
+ applied_style_changes=changes,
426
+ lines=lines,
427
+ font_pt=round(candidate, 2),
428
+ confidence="high",
429
+ overflow_detected=modest,
430
+ warnings=warnings,
431
+ field_id=field_id,
432
+ )
433
+
434
+ def _overflow_height(
435
+ self,
436
+ value: str,
437
+ slot: SlotMetrics,
438
+ policy: FitPolicy,
439
+ field_id: str | None,
440
+ wrapped_lines: int,
441
+ font_pt: float,
442
+ ) -> FitResult:
443
+ """Terminal action for a gross vertical balloon that shrink cannot resolve."""
444
+
445
+ budget = slot.height_lines(font_pt)
446
+ action = policy.overflow
447
+ # Line-level truncation is not modelled; keep the value and warn (honest).
448
+ if action == "truncate":
449
+ action = "warn"
450
+ if action == "warn":
451
+ return FitResult(
452
+ ok=True,
453
+ value=value,
454
+ applied_value=value,
455
+ lines=wrapped_lines,
456
+ font_pt=font_pt,
457
+ overflow_detected=True,
458
+ confidence="high",
459
+ warnings=[
460
+ f"value needs ~{wrapped_lines} lines but the row height holds "
461
+ f"~{budget}; the row will balloon (overflow=warn)"
462
+ ],
463
+ field_id=field_id,
464
+ )
465
+ return FitResult(
466
+ ok=False,
467
+ value=value,
468
+ applied_value=value,
469
+ lines=wrapped_lines,
470
+ font_pt=font_pt,
471
+ overflow_detected=True,
472
+ confidence="high",
473
+ errors=[
474
+ f"FIELD_OVERFLOW: value needs ~{wrapped_lines} lines but the row height "
475
+ f"holds ~{budget}; shrink/shorten the value or allow row expansion"
476
+ ],
477
+ field_id=field_id,
478
+ )
479
+
215
480
  # -- terminal overflow action ----------------------------------------- #
216
481
  def _overflow(
217
482
  self,
@@ -53,6 +53,31 @@ _CLASS_UNCERTAINTY: dict[str, float] = {
53
53
  # multiplicative factor so it scales and also buys headroom on the advance error.
54
54
  DEFAULT_SAFETY = 0.93
55
55
 
56
+ # --- Vertical (line-height) model ------------------------------------------- #
57
+ # Per-line vertical advance as a multiple of the em (font height in HWPUNIT).
58
+ # HWP's application default is 160% line spacing, so a line occupies ~1.6 em; this
59
+ # is the advance used when a paragraph declares no explicit lineSpacing, and it
60
+ # matches ``layout.lint._LINE_SPACING``. Used as the *expected* pitch for the
61
+ # authored vertical budget (the point at which the row is warned it will grow).
62
+ DEFAULT_LINE_SPACING_RATIO = 1.6
63
+
64
+ # Tightest per-line advance Hancom is observed to use (a glyph box with no leading).
65
+ # Measured on the M9 wild-form corpus: two soft-wrapped lines render inside cells
66
+ # only ~2.0 em tall, so the real pitch bottoms out near 1.0 em. Used to compute the
67
+ # *optimistic* (most generous) height budget, so a vertical overflow is only treated
68
+ # as high confidence when content overflows even at this tightest pitch.
69
+ MIN_LINE_SPACING_RATIO = 1.0
70
+
71
+ # A vertical overflow is a *hard* balloon (shrink-or-refuse) only when content needs
72
+ # at least this multiple of the optimistic authored line budget AND this many extra
73
+ # lines in absolute terms. Below the gross threshold the row may grow only modestly
74
+ # and the render oracle arbitrates. Measure-first calibration on the M9 corpus: a
75
+ # hard cap at the optimistic budget mis-refuses 47% of known-good multi-line cells,
76
+ # whereas this gross gate mis-refuses 2% — it fires on page-shifting balloons only.
77
+ # (Same balloon philosophy as ``layout.lint._BALLOON_FACTOR``.)
78
+ GROSS_ROW_GROWTH_FACTOR = 2.0
79
+ MIN_ROW_GROWTH_LINES = 2
80
+
56
81
  Confidence = Literal["high", "low"]
57
82
 
58
83
 
@@ -189,11 +214,69 @@ class SlotMetrics:
189
214
  max_lines: int = 1
190
215
  raw_width: float | None = None # cellSz.width before margins (diagnostics)
191
216
  source: str = "cell"
217
+ # Vertical budget. ``available_height`` is the usable inner height (HWPUNIT)
218
+ # after top/bottom cell margins + the safety inset. ``None`` means the vertical
219
+ # room was not (or could not be) measured, and the fit stays width-only.
220
+ available_height: float | None = None
221
+ # Per-line advance as a multiple of the em, from the cell's declared paragraph
222
+ # line spacing (PERCENT). ``None`` falls back to ``DEFAULT_LINE_SPACING_RATIO``.
223
+ line_spacing_ratio: float | None = None
224
+ # Width already consumed on the line by inline treat-as-char objects
225
+ # (checkboxes, form controls, pictures) that share the target paragraph.
226
+ # Their declared ``hp:sz/@width`` is subtracted from the usable width —
227
+ # ignoring them made the engine call "fits" on a fill that real Hancom
228
+ # wrapped, growing the row and repaginating a 10-page form (S-087 P0).
229
+ inline_object_width: float = 0.0
230
+ inline_object_count: int = 0
231
+ # A cell height existed but was unusable (merged row-span fragment, or an
232
+ # auto-grow floor shorter than one line). Records "height budget unavailable"
233
+ # so the fit reports width-only honestly rather than guessing a vertical fit.
234
+ height_unavailable: bool = False
192
235
 
193
236
  @property
194
237
  def capacity(self) -> float:
195
238
  return self.available_width * self.max_lines
196
239
 
240
+ def _line_ratio(self) -> float:
241
+ ratio = self.line_spacing_ratio
242
+ return ratio if ratio and ratio > 0 else DEFAULT_LINE_SPACING_RATIO
243
+
244
+ def line_height(self, font_pt: float | None = None) -> float:
245
+ """Expected per-line vertical advance in HWPUNIT at *font_pt*."""
246
+
247
+ pt = self.font_pt if font_pt is None else font_pt
248
+ return pt * 100.0 * self._line_ratio()
249
+
250
+ def height_lines(self, font_pt: float | None = None) -> int | None:
251
+ """Expected vertical line budget at *font_pt* (declared/default pitch).
252
+
253
+ ``None`` when the vertical room is unmeasured. Never less than 1 — a cell
254
+ always accommodates its first line; the risk we guard is *growth* past it.
255
+ """
256
+
257
+ if self.available_height is None:
258
+ return None
259
+ line_h = self.line_height(font_pt)
260
+ if line_h <= 0:
261
+ return None
262
+ return max(int(self.available_height // line_h), 1)
263
+
264
+ def height_lines_optimistic(self, font_pt: float | None = None) -> int | None:
265
+ """Most-generous vertical budget (tightest plausible pitch).
266
+
267
+ This is the basis for the *confidence* of a vertical overflow: content that
268
+ overflows even this budget is grossly too tall regardless of pitch error.
269
+ """
270
+
271
+ if self.available_height is None:
272
+ return None
273
+ pt = self.font_pt if font_pt is None else font_pt
274
+ ratio = min(self._line_ratio(), MIN_LINE_SPACING_RATIO)
275
+ line_h = pt * 100.0 * ratio
276
+ if line_h <= 0:
277
+ return None
278
+ return max(int(self.available_height // line_h), 1)
279
+
197
280
 
198
281
  @dataclass(slots=True)
199
282
  class Measurement:
@@ -291,6 +374,50 @@ def _cell_margin(cell_element: object) -> tuple[int, int]:
291
374
  return (0, 0)
292
375
 
293
376
 
377
+ def _cell_margin_vertical(cell_element: object) -> tuple[int, int]:
378
+ """Return (top, bottom) cellMargin in HWPUNIT, defaulting to 0."""
379
+
380
+ for child in cell_element: # type: ignore[attr-defined]
381
+ if _local_name(child.tag) == "cellMargin":
382
+ return (
383
+ int(child.get("top", "0") or 0),
384
+ int(child.get("bottom", "0") or 0),
385
+ )
386
+ return (0, 0)
387
+
388
+
389
+ def _first_para_line_spacing_ratio(cell: object, document: object) -> float | None:
390
+ """Per-line em multiple from the cell's first paragraph line spacing (PERCENT).
391
+
392
+ Only PERCENT spacing maps cleanly onto the em-relative line-height model; FIXED
393
+ / ATLEAST / BETWEENLINES are left to the conservative default so we never invent
394
+ a font-independent pitch (the shrink ladder varies the font).
395
+ """
396
+
397
+ try:
398
+ paragraphs = cell.paragraphs # type: ignore[attr-defined]
399
+ except Exception: # pragma: no cover - defensive
400
+ return None
401
+ for paragraph in paragraphs:
402
+ ref = getattr(paragraph, "para_pr_id_ref", None)
403
+ if ref is None or document is None:
404
+ continue
405
+ try:
406
+ prop = document.paragraph_property(ref) # type: ignore[attr-defined]
407
+ except Exception: # pragma: no cover - defensive
408
+ prop = None
409
+ spacing = getattr(prop, "line_spacing", None) if prop is not None else None
410
+ if spacing is None or not getattr(spacing, "value", None):
411
+ continue
412
+ if (getattr(spacing, "spacing_type", None) or "PERCENT").upper() != "PERCENT":
413
+ return None
414
+ try:
415
+ return int(spacing.value) / 100.0
416
+ except (TypeError, ValueError): # pragma: no cover - defensive
417
+ return None
418
+ return None
419
+
420
+
294
421
  def _first_run_font_pt(cell: object, document: object) -> float:
295
422
  """Resolve the cell's first run font size in points (default 10pt)."""
296
423
 
@@ -339,27 +466,107 @@ def resolve_slot_metrics(
339
466
  verified against Hancom's own ``lineSeg/@horzsize`` (±10 HWPUNIT on 82% of
340
467
  cells; the safety factor covers the rest plus paragraph indent, which is left
341
468
  to the HarfBuzz pass).
469
+
470
+ ``available_height`` follows the same philosophy — ``(cellSz.height - top -
471
+ bottom margin) * safety`` — but is recorded as *unavailable* (``None`` +
472
+ ``height_unavailable``) whenever the authored height is not a real ceiling:
473
+ a merged cell (its ``cellSz.height`` is a single-row fragment, not the spanned
474
+ height), a cell with no height, or an auto-grow floor shorter than one line
475
+ (Hancom simply grows the row past it). The fit then stays width-only rather
476
+ than guess a vertical fit.
342
477
  """
343
478
 
344
479
  raw_width = float(getattr(cell, "width", 0) or 0)
345
480
  element = getattr(cell, "element", None)
346
481
  left, right = _cell_margin(element) if element is not None else (0, 0)
347
482
  inner = max(raw_width - left - right, 0.0) * safety
483
+ inline_width, inline_count = (
484
+ _inline_object_width(element) if element is not None else (0.0, 0)
485
+ )
486
+ if inline_width:
487
+ inner = max(inner - inline_width, 0.0)
348
488
  resolved_pt = font_pt if font_pt is not None else _first_run_font_pt(cell, document)
489
+ line_ratio = _first_para_line_spacing_ratio(cell, document)
490
+
491
+ available_height: float | None = None
492
+ height_unavailable = False
493
+ raw_height = float(getattr(cell, "height", 0) or 0)
494
+ try:
495
+ row_span = int(getattr(cell, "span", (1, 1))[0])
496
+ except Exception: # pragma: no cover - defensive
497
+ row_span = 1
498
+ top, bottom = _cell_margin_vertical(element) if element is not None else (0, 0)
499
+ inner_h = max(raw_height - top - bottom, 0.0) * safety if raw_height > 0 else 0.0
500
+ # A cell authored shorter than one line at the tightest pitch is an auto-grow
501
+ # floor, not a ceiling (Hancom grows the row past it); a merged row-span's
502
+ # cellSz.height is only one of the spanned rows. Neither is a usable budget.
503
+ one_line = resolved_pt * 100.0 * MIN_LINE_SPACING_RATIO
504
+ if row_span <= 1 and inner_h >= one_line:
505
+ available_height = inner_h
506
+ else:
507
+ height_unavailable = True
508
+
349
509
  return SlotMetrics(
350
510
  available_width=inner,
351
511
  font_pt=resolved_pt,
352
512
  max_lines=max(max_lines, 1),
353
513
  raw_width=raw_width,
354
514
  source="cell",
515
+ available_height=available_height,
516
+ line_spacing_ratio=line_ratio,
517
+ height_unavailable=height_unavailable,
518
+ inline_object_width=inline_width,
519
+ inline_object_count=inline_count,
355
520
  )
356
521
 
357
522
 
523
+ def _inline_object_width(cell_element: object) -> tuple[float, int]:
524
+ """Total declared width of inline treat-as-char objects in the cell.
525
+
526
+ Checkboxes and similar form controls flow on the text line
527
+ (``hp:pos/@treatAsChar='1'``) and consume their ``hp:sz/@width``; a fill
528
+ value shares whatever width remains. Objects without a declared size are
529
+ counted but contribute 0 width (the count still signals reduced trust).
530
+ """
531
+
532
+ total = 0.0
533
+ count = 0
534
+ iter_fn = getattr(cell_element, "iter", None)
535
+ if iter_fn is None:
536
+ return 0.0, 0
537
+ for node in iter_fn():
538
+ tag = getattr(node, "tag", "")
539
+ if not isinstance(tag, str):
540
+ continue
541
+ local = tag.rsplit("}", 1)[-1].lower()
542
+ if local != "pos":
543
+ continue
544
+ if (node.get("treatAsChar") or "").strip() not in {"1", "true", "TRUE"}:
545
+ continue
546
+ holder = node.getparent() if hasattr(node, "getparent") else None
547
+ if holder is None:
548
+ continue
549
+ count += 1
550
+ for sibling in holder:
551
+ sib_local = str(getattr(sibling, "tag", "")).rsplit("}", 1)[-1].lower()
552
+ if sib_local == "sz":
553
+ try:
554
+ total += float(sibling.get("width") or 0)
555
+ except (TypeError, ValueError):
556
+ pass
557
+ break
558
+ return total, count
559
+
560
+
358
561
  __all__ = [
359
562
  "SlotMetrics",
360
563
  "Measurement",
361
564
  "Confidence",
362
565
  "DEFAULT_SAFETY",
566
+ "DEFAULT_LINE_SPACING_RATIO",
567
+ "MIN_LINE_SPACING_RATIO",
568
+ "GROSS_ROW_GROWTH_FACTOR",
569
+ "MIN_ROW_GROWTH_LINES",
363
570
  "classify_char",
364
571
  "char_advance",
365
572
  "estimate_text_width",
@@ -159,10 +159,69 @@ def _local_name(element: etree._Element) -> str:
159
159
  return tag
160
160
 
161
161
 
162
+ def _paragraph_plain_text_length(paragraph: etree._Element) -> int | None:
163
+ """Plain text length of a simple paragraph, or ``None`` when unjudgeable.
164
+
165
+ Mirrors the oxml-side stale detector: only paragraphs made purely of runs
166
+ with text/tab-like children can be judged at the byte boundary; anything
167
+ else (controls, tables, fields) returns ``None`` so its cache is kept.
168
+ """
169
+ total = 0
170
+ for child in paragraph:
171
+ child_name = _local_name(child).lower()
172
+ if child_name in _LAYOUT_CACHE_ELEMENT_NAMES:
173
+ continue
174
+ if child_name != "run":
175
+ return None
176
+ for run_child in child:
177
+ run_child_name = _local_name(run_child).lower()
178
+ if run_child_name == "t":
179
+ total += len("".join(run_child.itertext()))
180
+ elif run_child_name in {"tab", "linebreak", "hyphen", "nbspace"}:
181
+ total += 1
182
+ else:
183
+ return None
184
+ return total
185
+
186
+
187
+ def _paragraph_layout_cache_is_stale(paragraph: etree._Element) -> bool:
188
+ text_length = _paragraph_plain_text_length(paragraph)
189
+ if text_length is None:
190
+ return False
191
+ for child in paragraph:
192
+ if _local_name(child).lower() not in _LAYOUT_CACHE_ELEMENT_NAMES:
193
+ continue
194
+ for line_seg in child:
195
+ if _local_name(line_seg).lower() != "lineseg":
196
+ continue
197
+ textpos = line_seg.get("textpos")
198
+ if textpos is None:
199
+ continue
200
+ try:
201
+ if int(textpos) > text_length:
202
+ return True
203
+ except ValueError:
204
+ return True
205
+ return False
206
+
207
+
162
208
  def _strip_section_layout_caches(payload: bytes) -> bytes:
209
+ """Drop only *stale* paragraph layout caches from a section payload.
210
+
211
+ Valid caches are the authored layout of untouched paragraphs; removing
212
+ them forces the editor to re-lay-out entire multi-page documents, which
213
+ shifted page counts and stacked glyphs in the wild form-fill differential
214
+ (specs/031 P0 receipt). The mutating APIs upstream clear the caches of
215
+ exactly the paragraphs they touch, so this byte-boundary sweep only
216
+ guards against writers that left a provably stale cache behind.
217
+ """
163
218
  root = parse_xml(payload)
164
219
  removed = False
165
220
  for parent in root.iter():
221
+ if _local_name(parent).lower() != "p":
222
+ continue
223
+ if not _paragraph_layout_cache_is_stale(parent):
224
+ continue
166
225
  for child in list(parent):
167
226
  if _local_name(child).lower() in _LAYOUT_CACHE_ELEMENT_NAMES:
168
227
  parent.remove(child)