docvortex 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (364) hide show
  1. docvortex/__init__.py +21 -0
  2. docvortex/analyzers/__init__.py +3 -0
  3. docvortex/analyzers/native/__init__.py +37 -0
  4. docvortex/analyzers/native/_shared/__init__.py +3 -0
  5. docvortex/analyzers/native/_shared/hyperlink.py +16 -0
  6. docvortex/analyzers/native/_shared/image.py +8 -0
  7. docvortex/analyzers/native/_shared/markup/__init__.py +47 -0
  8. docvortex/analyzers/native/_shared/markup/anchors.py +30 -0
  9. docvortex/analyzers/native/_shared/markup/formula.py +37 -0
  10. docvortex/analyzers/native/_shared/markup/projector.py +57 -0
  11. docvortex/analyzers/native/_shared/markup/styles.py +19 -0
  12. docvortex/analyzers/native/_shared/mathml.py +17 -0
  13. docvortex/analyzers/native/_shared/names.py +7 -0
  14. docvortex/analyzers/native/_shared/xycut.py +414 -0
  15. docvortex/analyzers/native/contracts.py +44 -0
  16. docvortex/analyzers/native/csv.py +351 -0
  17. docvortex/analyzers/native/epub/__init__.py +18 -0
  18. docvortex/analyzers/native/epub/constants.py +45 -0
  19. docvortex/analyzers/native/epub/converter.py +80 -0
  20. docvortex/analyzers/native/epub/errors.py +20 -0
  21. docvortex/analyzers/native/epub/metadata.py +31 -0
  22. docvortex/analyzers/native/epub/package.py +649 -0
  23. docvortex/analyzers/native/epub/xhtml.py +306 -0
  24. docvortex/analyzers/native/html/__init__.py +6 -0
  25. docvortex/analyzers/native/html/anchors.py +271 -0
  26. docvortex/analyzers/native/html/constants.py +25 -0
  27. docvortex/analyzers/native/html/contracts.py +7 -0
  28. docvortex/analyzers/native/html/converter.py +117 -0
  29. docvortex/analyzers/native/html/document.py +389 -0
  30. docvortex/analyzers/native/html/errors.py +12 -0
  31. docvortex/analyzers/native/html/resources.py +365 -0
  32. docvortex/analyzers/native/html/selector.py +421 -0
  33. docvortex/analyzers/native/models.py +209 -0
  34. docvortex/analyzers/native/ofd/__init__.py +14 -0
  35. docvortex/analyzers/native/ofd/constants.py +59 -0
  36. docvortex/analyzers/native/ofd/converter.py +31 -0
  37. docvortex/analyzers/native/ofd/errors.py +16 -0
  38. docvortex/analyzers/native/ofd/geometry.py +215 -0
  39. docvortex/analyzers/native/ofd/images.py +124 -0
  40. docvortex/analyzers/native/ofd/metadata.py +53 -0
  41. docvortex/analyzers/native/ofd/models.py +170 -0
  42. docvortex/analyzers/native/ofd/package.py +346 -0
  43. docvortex/analyzers/native/ofd/path.py +201 -0
  44. docvortex/analyzers/native/ofd/reading_order.py +293 -0
  45. docvortex/analyzers/native/ofd/resources.py +122 -0
  46. docvortex/analyzers/native/ofd/scene.py +347 -0
  47. docvortex/analyzers/native/ofd/table.py +240 -0
  48. docvortex/analyzers/native/ofd/text.py +521 -0
  49. docvortex/analyzers/native/office/__init__.py +3 -0
  50. docvortex/analyzers/native/office/doc/__init__.py +3 -0
  51. docvortex/analyzers/native/office/doc/bookmarks.py +81 -0
  52. docvortex/analyzers/native/office/doc/doc_converter.py +573 -0
  53. docvortex/analyzers/native/office/doc/fib.py +261 -0
  54. docvortex/analyzers/native/office/doc/fields.py +101 -0
  55. docvortex/analyzers/native/office/doc/formatting.py +171 -0
  56. docvortex/analyzers/native/office/doc/images.py +162 -0
  57. docvortex/analyzers/native/office/doc/lists.py +356 -0
  58. docvortex/analyzers/native/office/doc/models.py +164 -0
  59. docvortex/analyzers/native/office/doc/parser.py +851 -0
  60. docvortex/analyzers/native/office/doc/pieces.py +256 -0
  61. docvortex/analyzers/native/office/doc/records.py +40 -0
  62. docvortex/analyzers/native/office/doc/sprm.py +269 -0
  63. docvortex/analyzers/native/office/doc/styles.py +215 -0
  64. docvortex/analyzers/native/office/docx/__init__.py +3 -0
  65. docvortex/analyzers/native/office/docx/context.py +71 -0
  66. docvortex/analyzers/native/office/docx/docx_converter.py +614 -0
  67. docvortex/analyzers/native/office/docx/equationxml.py +119 -0
  68. docvortex/analyzers/native/office/docx/fields.py +816 -0
  69. docvortex/analyzers/native/office/docx/formatting_types.py +23 -0
  70. docvortex/analyzers/native/office/docx/main.py +50 -0
  71. docvortex/analyzers/native/office/docx/numbering.py +491 -0
  72. docvortex/analyzers/native/office/docx/office_xml.py +57 -0
  73. docvortex/analyzers/native/office/docx/package_normalizer.py +248 -0
  74. docvortex/analyzers/native/office/docx/resources.py +526 -0
  75. docvortex/analyzers/native/office/docx/styles.py +693 -0
  76. docvortex/analyzers/native/office/docx/tables.py +515 -0
  77. docvortex/analyzers/native/office/equation/__init__.py +3 -0
  78. docvortex/analyzers/native/office/equation/image.py +470 -0
  79. docvortex/analyzers/native/office/equation/latex_dict.py +324 -0
  80. docvortex/analyzers/native/office/equation/mtef.py +885 -0
  81. docvortex/analyzers/native/office/equation/mtef_v5.py +941 -0
  82. docvortex/analyzers/native/office/equation/omml.py +561 -0
  83. docvortex/analyzers/native/office/equation/ooxml.py +62 -0
  84. docvortex/analyzers/native/office/errors.py +33 -0
  85. docvortex/analyzers/native/office/image.py +307 -0
  86. docvortex/analyzers/native/office/legacy/__init__.py +3 -0
  87. docvortex/analyzers/native/office/legacy/binary.py +43 -0
  88. docvortex/analyzers/native/office/legacy/officeart.py +362 -0
  89. docvortex/analyzers/native/office/legacy/ole.py +110 -0
  90. docvortex/analyzers/native/office/limits.py +12 -0
  91. docvortex/analyzers/native/office/odf/__init__.py +3 -0
  92. docvortex/analyzers/native/office/odf/chart.py +76 -0
  93. docvortex/analyzers/native/office/odf/constants.py +70 -0
  94. docvortex/analyzers/native/office/odf/converters.py +403 -0
  95. docvortex/analyzers/native/office/odf/errors.py +18 -0
  96. docvortex/analyzers/native/office/odf/metadata.py +91 -0
  97. docvortex/analyzers/native/office/odf/models.py +176 -0
  98. docvortex/analyzers/native/office/odf/package.py +270 -0
  99. docvortex/analyzers/native/office/odf/styles.py +329 -0
  100. docvortex/analyzers/native/office/odf/table.py +469 -0
  101. docvortex/analyzers/native/office/odf/text.py +1002 -0
  102. docvortex/analyzers/native/office/ooxml_chart.py +1016 -0
  103. docvortex/analyzers/native/office/opc.py +38 -0
  104. docvortex/analyzers/native/office/ppt/__init__.py +3 -0
  105. docvortex/analyzers/native/office/ppt/models.py +118 -0
  106. docvortex/analyzers/native/office/ppt/parser.py +1895 -0
  107. docvortex/analyzers/native/office/ppt/ppt_converter.py +292 -0
  108. docvortex/analyzers/native/office/ppt/records.py +131 -0
  109. docvortex/analyzers/native/office/ppt/style_text.py +247 -0
  110. docvortex/analyzers/native/office/pptx/__init__.py +3 -0
  111. docvortex/analyzers/native/office/pptx/context.py +113 -0
  112. docvortex/analyzers/native/office/pptx/lists.py +558 -0
  113. docvortex/analyzers/native/office/pptx/main.py +20 -0
  114. docvortex/analyzers/native/office/pptx/package_normalizer.py +321 -0
  115. docvortex/analyzers/native/office/pptx/pptx_converter.py +323 -0
  116. docvortex/analyzers/native/office/pptx/resources.py +329 -0
  117. docvortex/analyzers/native/office/pptx/shapes.py +393 -0
  118. docvortex/analyzers/native/office/pptx/text_styles.py +625 -0
  119. docvortex/analyzers/native/office/pptx/titles.py +178 -0
  120. docvortex/analyzers/native/office/rich_text.py +420 -0
  121. docvortex/analyzers/native/office/rtf/__init__.py +3 -0
  122. docvortex/analyzers/native/office/rtf/converter.py +708 -0
  123. docvortex/analyzers/native/office/rtf/lexer.py +213 -0
  124. docvortex/analyzers/native/office/rtf/math.py +339 -0
  125. docvortex/analyzers/native/office/rtf/models.py +185 -0
  126. docvortex/analyzers/native/office/rtf/parser.py +1553 -0
  127. docvortex/analyzers/native/office/spreadsheet/__init__.py +3 -0
  128. docvortex/analyzers/native/office/spreadsheet/html.py +81 -0
  129. docvortex/analyzers/native/office/spreadsheet/models.py +79 -0
  130. docvortex/analyzers/native/office/spreadsheet/projector.py +928 -0
  131. docvortex/analyzers/native/office/streams.py +18 -0
  132. docvortex/analyzers/native/office/xls/__init__.py +3 -0
  133. docvortex/analyzers/native/office/xls/chart.py +132 -0
  134. docvortex/analyzers/native/office/xls/embedded_chart.py +299 -0
  135. docvortex/analyzers/native/office/xls/models.py +109 -0
  136. docvortex/analyzers/native/office/xls/number_format.py +521 -0
  137. docvortex/analyzers/native/office/xls/parser.py +1145 -0
  138. docvortex/analyzers/native/office/xls/records.py +201 -0
  139. docvortex/analyzers/native/office/xls/strings.py +205 -0
  140. docvortex/analyzers/native/office/xls/xls_converter.py +354 -0
  141. docvortex/analyzers/native/office/xlsx/__init__.py +3 -0
  142. docvortex/analyzers/native/office/xlsx/main.py +20 -0
  143. docvortex/analyzers/native/office/xlsx/ooxml_ole.py +522 -0
  144. docvortex/analyzers/native/office/xlsx/package_normalizer.py +310 -0
  145. docvortex/analyzers/native/office/xlsx/xlsx_converter.py +716 -0
  146. docvortex/analyzers/native/pdf/__init__.py +3 -0
  147. docvortex/analyzers/native/pdf/auxiliary_text.py +1671 -0
  148. docvortex/analyzers/native/pdf/char_geometry.py +1662 -0
  149. docvortex/analyzers/native/pdf/code_blocks.py +535 -0
  150. docvortex/analyzers/native/pdf/formulas.py +1985 -0
  151. docvortex/analyzers/native/pdf/geometry.py +281 -0
  152. docvortex/analyzers/native/pdf/graphics.py +1501 -0
  153. docvortex/analyzers/native/pdf/index_blocks.py +268 -0
  154. docvortex/analyzers/native/pdf/inline/__init__.py +3 -0
  155. docvortex/analyzers/native/pdf/inline/common.py +112 -0
  156. docvortex/analyzers/native/pdf/inline/detection.py +590 -0
  157. docvortex/analyzers/native/pdf/inline/matching.py +1185 -0
  158. docvortex/analyzers/native/pdf/inline/materialize.py +457 -0
  159. docvortex/analyzers/native/pdf/inline/scripts.py +975 -0
  160. docvortex/analyzers/native/pdf/inline/types.py +385 -0
  161. docvortex/analyzers/native/pdf/line_layout.py +1106 -0
  162. docvortex/analyzers/native/pdf/line_merging.py +1223 -0
  163. docvortex/analyzers/native/pdf/models.py +246 -0
  164. docvortex/analyzers/native/pdf/native_text.py +1004 -0
  165. docvortex/analyzers/native/pdf/pipeline.py +1390 -0
  166. docvortex/analyzers/native/pdf/script_geometry.py +636 -0
  167. docvortex/analyzers/native/pdf/shared.py +30 -0
  168. docvortex/analyzers/native/pdf/spatial_text.py +383 -0
  169. docvortex/analyzers/native/pdf/table_annotations.py +446 -0
  170. docvortex/analyzers/native/pdf/table_constants.py +48 -0
  171. docvortex/analyzers/native/pdf/table_detection.py +227 -0
  172. docvortex/analyzers/native/pdf/table_filled_grid.py +218 -0
  173. docvortex/analyzers/native/pdf/table_geometry.py +40 -0
  174. docvortex/analyzers/native/pdf/table_materialization.py +444 -0
  175. docvortex/analyzers/native/pdf/table_recovery/__init__.py +15 -0
  176. docvortex/analyzers/native/pdf/table_recovery/candidate.py +356 -0
  177. docvortex/analyzers/native/pdf/table_recovery/contracts.py +154 -0
  178. docvortex/analyzers/native/pdf/table_recovery/engine.py +609 -0
  179. docvortex/analyzers/native/pdf/table_recovery/geometry.py +164 -0
  180. docvortex/analyzers/native/pdf/table_recovery/sparse_common.py +85 -0
  181. docvortex/analyzers/native/pdf/table_recovery/sparse_hybrid.py +804 -0
  182. docvortex/analyzers/native/pdf/table_recovery/sparse_multiline.py +1122 -0
  183. docvortex/analyzers/native/pdf/table_recovery/text.py +414 -0
  184. docvortex/analyzers/native/pdf/table_recovery/text_grid.py +618 -0
  185. docvortex/analyzers/native/pdf/table_recovery/vector.py +1931 -0
  186. docvortex/analyzers/native/pdf/table_rows.py +34 -0
  187. docvortex/analyzers/native/pdf/table_rules.py +1129 -0
  188. docvortex/analyzers/native/pdf/table_text_styles.py +283 -0
  189. docvortex/analyzers/native/pdf/tables.py +147 -0
  190. docvortex/analyzers/native/pdf/text_assembly/__init__.py +3 -0
  191. docvortex/analyzers/native/pdf/text_assembly/annotations.py +581 -0
  192. docvortex/analyzers/native/pdf/text_assembly/assembly.py +292 -0
  193. docvortex/analyzers/native/pdf/text_assembly/common.py +477 -0
  194. docvortex/analyzers/native/pdf/text_assembly/footnotes.py +394 -0
  195. docvortex/analyzers/native/pdf/text_assembly/merging.py +1274 -0
  196. docvortex/analyzers/native/pdf/text_assembly/rows.py +692 -0
  197. docvortex/analyzers/native/pdf/text_blocks.py +82 -0
  198. docvortex/analyzers/native/pdf/text_styles.py +55 -0
  199. docvortex/analyzers/native/pdf/title_analysis/__init__.py +3 -0
  200. docvortex/analyzers/native/pdf/title_analysis/body_profile.py +215 -0
  201. docvortex/analyzers/native/pdf/title_analysis/common.py +117 -0
  202. docvortex/analyzers/native/pdf/title_analysis/document_profile.py +164 -0
  203. docvortex/analyzers/native/pdf/title_analysis/lane_titles.py +758 -0
  204. docvortex/analyzers/native/pdf/title_analysis/page_titles.py +1024 -0
  205. docvortex/analyzers/native/pdf/title_analysis/prototype.py +194 -0
  206. docvortex/analyzers/native/pdf/title_analysis/structural.py +1081 -0
  207. docvortex/analyzers/native/pdf/titles.py +75 -0
  208. docvortex/analyzers/native/pdf/typography.py +19 -0
  209. docvortex/analyzers/native/pdf/visual_annotations.py +1262 -0
  210. docvortex/api.py +180 -0
  211. docvortex/assets/__init__.py +5 -0
  212. docvortex/assets/store.py +51 -0
  213. docvortex/cli.py +54 -0
  214. docvortex/codecs/__init__.py +3 -0
  215. docvortex/codecs/html/__init__.py +22 -0
  216. docvortex/codecs/html/contracts.py +236 -0
  217. docvortex/codecs/html/materializer.py +331 -0
  218. docvortex/codecs/html/parser.py +763 -0
  219. docvortex/codecs/html/resources.py +34 -0
  220. docvortex/codecs/json.py +17 -0
  221. docvortex/content/__init__.py +5 -0
  222. docvortex/content/inline.py +248 -0
  223. docvortex/content/markup/__init__.py +44 -0
  224. docvortex/content/markup/anchors.py +188 -0
  225. docvortex/content/markup/formula.py +280 -0
  226. docvortex/content/markup/projector.py +1237 -0
  227. docvortex/content/markup/styles.py +327 -0
  228. docvortex/content/mathml.py +167 -0
  229. docvortex/content/normalization.py +188 -0
  230. docvortex/content/spans.py +183 -0
  231. docvortex/content/table/__init__.py +18 -0
  232. docvortex/content/table/blocks.py +152 -0
  233. docvortex/content/table/content.py +425 -0
  234. docvortex/content/table/document.py +104 -0
  235. docvortex/content/table/html.py +399 -0
  236. docvortex/content/table/models.py +76 -0
  237. docvortex/content/table/rules.py +42 -0
  238. docvortex/content/table/structure.py +221 -0
  239. docvortex/content/tree.py +5 -0
  240. docvortex/document/__init__.py +3 -0
  241. docvortex/document/contracts.py +22 -0
  242. docvortex/document/detection.py +389 -0
  243. docvortex/document/filetypes.py +175 -0
  244. docvortex/document/page_range.py +167 -0
  245. docvortex/document/pdf/__init__.py +19 -0
  246. docvortex/document/pdf/classify.py +1138 -0
  247. docvortex/document/pdf/constants.py +132 -0
  248. docvortex/document/pdf/diagnostics.py +350 -0
  249. docvortex/document/pdf/document.py +582 -0
  250. docvortex/document/pdf/font_runtime.py +335 -0
  251. docvortex/document/pdf/geometry.py +31 -0
  252. docvortex/document/pdf/images.py +654 -0
  253. docvortex/document/pdf/native_annotations.py +367 -0
  254. docvortex/document/pdf/native_contracts.py +169 -0
  255. docvortex/document/pdf/native_coordinates.py +216 -0
  256. docvortex/document/pdf/native_lifecycle.py +16 -0
  257. docvortex/document/pdf/native_objects.py +902 -0
  258. docvortex/document/pdf/native_text_geometry.py +315 -0
  259. docvortex/document/pdf/pdfium.py +325 -0
  260. docvortex/document/pdf/raster.py +46 -0
  261. docvortex/document/pdf/text/__init__.py +62 -0
  262. docvortex/document/pdf/text/contracts.py +211 -0
  263. docvortex/document/pdf/text/extract.py +165 -0
  264. docvortex/document/pdf/text/geometry.py +16 -0
  265. docvortex/document/pdf/text/groups.py +162 -0
  266. docvortex/document/pdf/visual_geometry.py +201 -0
  267. docvortex/document/pdf/visuals.py +343 -0
  268. docvortex/document/source.py +92 -0
  269. docvortex/errors.py +21 -0
  270. docvortex/export/__init__.py +3 -0
  271. docvortex/export/bundle.py +98 -0
  272. docvortex/export/files.py +66 -0
  273. docvortex/export/middle.py +208 -0
  274. docvortex/foundation/__init__.py +3 -0
  275. docvortex/foundation/geometry.py +125 -0
  276. docvortex/foundation/hyperlink.py +65 -0
  277. docvortex/foundation/image.py +48 -0
  278. docvortex/foundation/image_encoding.py +30 -0
  279. docvortex/foundation/image_payload.py +280 -0
  280. docvortex/foundation/language.py +92 -0
  281. docvortex/foundation/platform.py +38 -0
  282. docvortex/foundation/text.py +153 -0
  283. docvortex/foundation/type_identity.py +20 -0
  284. docvortex/foundation/xml_names.py +20 -0
  285. docvortex/options.py +30 -0
  286. docvortex/postprocess/__init__.py +3 -0
  287. docvortex/postprocess/content.py +53 -0
  288. docvortex/postprocess/document.py +19 -0
  289. docvortex/postprocess/lists.py +236 -0
  290. docvortex/postprocess/page_blocks.py +214 -0
  291. docvortex/postprocess/pages.py +95 -0
  292. docvortex/postprocess/paragraphs.py +580 -0
  293. docvortex/postprocess/visual.py +715 -0
  294. docvortex/render/__init__.py +48 -0
  295. docvortex/render/_internal/__init__.py +3 -0
  296. docvortex/render/_internal/common/__init__.py +3 -0
  297. docvortex/render/_internal/common/context.py +43 -0
  298. docvortex/render/_internal/common/html_table.py +178 -0
  299. docvortex/render/_internal/common/index.py +33 -0
  300. docvortex/render/_internal/common/list_items.py +158 -0
  301. docvortex/render/_internal/common/planner.py +140 -0
  302. docvortex/render/_internal/docx/__init__.py +3 -0
  303. docvortex/render/_internal/docx/assets.py +202 -0
  304. docvortex/render/_internal/docx/inline.py +434 -0
  305. docvortex/render/_internal/docx/math.py +220 -0
  306. docvortex/render/_internal/docx/renderer.py +905 -0
  307. docvortex/render/_internal/docx/styles.py +195 -0
  308. docvortex/render/_internal/docx/table.py +442 -0
  309. docvortex/render/_internal/epub/__init__.py +5 -0
  310. docvortex/render/_internal/epub/assets.py +173 -0
  311. docvortex/render/_internal/epub/package.py +249 -0
  312. docvortex/render/_internal/epub/renderer.py +1156 -0
  313. docvortex/render/_internal/html/__init__.py +3 -0
  314. docvortex/render/_internal/html/inline.py +346 -0
  315. docvortex/render/_internal/html/renderer.py +1041 -0
  316. docvortex/render/_internal/html/sanitizer.py +478 -0
  317. docvortex/render/_internal/html/table.py +121 -0
  318. docvortex/render/_internal/latex/__init__.py +1 -0
  319. docvortex/render/_internal/latex/assets.py +85 -0
  320. docvortex/render/_internal/latex/inline.py +145 -0
  321. docvortex/render/_internal/latex/renderer.py +506 -0
  322. docvortex/render/_internal/latex/table.py +347 -0
  323. docvortex/render/_internal/markdown/__init__.py +3 -0
  324. docvortex/render/_internal/markdown/assets.py +78 -0
  325. docvortex/render/_internal/markdown/blocks.py +635 -0
  326. docvortex/render/_internal/markdown/escaping.py +51 -0
  327. docvortex/render/_internal/markdown/inline.py +260 -0
  328. docvortex/render/_internal/markdown/renderer.py +93 -0
  329. docvortex/render/_internal/markdown/table.py +281 -0
  330. docvortex/render/_internal/pdf/__init__.py +3 -0
  331. docvortex/render/_internal/pdf/assets.py +197 -0
  332. docvortex/render/_internal/pdf/formula.py +417 -0
  333. docvortex/render/_internal/pdf/inline.py +343 -0
  334. docvortex/render/_internal/pdf/renderer.py +734 -0
  335. docvortex/render/_internal/pdf/styles.py +206 -0
  336. docvortex/render/_internal/pdf/table.py +272 -0
  337. docvortex/render/_internal/structured_content/__init__.py +3 -0
  338. docvortex/render/_internal/structured_content/renderer.py +193 -0
  339. docvortex/render/api.py +199 -0
  340. docvortex/render/contracts.py +205 -0
  341. docvortex/render/docx.py +38 -0
  342. docvortex/render/epub.py +35 -0
  343. docvortex/render/fragments.py +70 -0
  344. docvortex/render/html.py +29 -0
  345. docvortex/render/latex.py +24 -0
  346. docvortex/render/markdown.py +50 -0
  347. docvortex/render/pdf.py +25 -0
  348. docvortex/render/structured_content.py +23 -0
  349. docvortex/resources/epub/docvortex.css +91 -0
  350. docvortex/resources/fasttext-langdetect/lid.176.ftz +0 -0
  351. docvortex/resources/fonts/DroidSansFallbackFull.ttf +0 -0
  352. docvortex/resources/fonts/NOTICE +190 -0
  353. docvortex/resources/fonts/manifest.json +9 -0
  354. docvortex/resources/html/docvortex.css +601 -0
  355. docvortex/resources/html/docvortex.min.css +1 -0
  356. docvortex/result.py +91 -0
  357. docvortex/schema.py +1141 -0
  358. docvortex/version.py +3 -0
  359. docvortex-0.2.1.dist-info/METADATA +193 -0
  360. docvortex-0.2.1.dist-info/RECORD +364 -0
  361. docvortex-0.2.1.dist-info/WHEEL +5 -0
  362. docvortex-0.2.1.dist-info/entry_points.txt +2 -0
  363. docvortex-0.2.1.dist-info/licenses/LICENSE.md +21 -0
  364. docvortex-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,804 @@
1
+ """融合稀疏物理边界和文本对齐网络恢复少线表格结构。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import statistics
7
+ from collections import Counter
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from .sparse_common import _LocalRule, _local_rules, cluster_members
12
+ from .candidate import GridCellSpec, build_candidate
13
+ from .contracts import NativeTableCandidate, NativeTableInput, NativeTableText, NativeTableTextRow
14
+ from .geometry import covered_interval_ratio, normalize_angle, normalize_bbox, page_bbox_to_table_local, table_local_size
15
+
16
+ MIN_COLUMN_SUPPORT = 0.60
17
+ MIN_OVERALL_ANCHOR_SUPPORT = 0.80
18
+ MIN_SPARSE_RELIABILITY = 0.98
19
+ MAX_SPARSE_HYPOTHESES = 8
20
+ MAX_HEADER_ROWS = 2
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class _TrackHypothesis:
25
+ """保存一组少线表叶子列轨及其独立证据。"""
26
+
27
+ evidence: str
28
+ x_tracks: tuple[float, ...]
29
+ physical_boundaries: frozenset[int]
30
+ body_start: int
31
+ reliability: float
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class _DenseLayout:
36
+ """保存正文稠密行推断出的列数和连续正文起点。"""
37
+
38
+ target_cols: int
39
+ body_start: int
40
+ dense_row_indices: tuple[int, ...]
41
+
42
+
43
+ def _long_horizontal_rules(
44
+ rules: tuple[_LocalRule, ...],
45
+ width: float,
46
+ ) -> tuple[_LocalRule, ...]:
47
+ """筛选能够独立证明表带存在的长横线。"""
48
+
49
+ return tuple(rule for rule in rules if rule.orientation == "horizontal" and rule.end - rule.start >= 0.50 * width)
50
+
51
+
52
+ def _vertical_track_evidence(
53
+ rules: tuple[_LocalRule, ...],
54
+ width: float,
55
+ height: float,
56
+ tolerance: float,
57
+ ) -> tuple[tuple[float, ...], dict[float, float]]:
58
+ """合并同 X 分段竖线并返回覆盖足够的物理列轨。"""
59
+
60
+ vertical_rules = [rule for rule in rules if rule.orientation == "vertical"]
61
+ clusters = cluster_members(
62
+ [rule.coordinate for rule in vertical_rules],
63
+ tolerance,
64
+ )
65
+ positions: list[float] = []
66
+ coverages: dict[float, float] = {}
67
+ for coordinate, _members in clusters:
68
+ intervals = [(rule.start, rule.end) for rule in vertical_rules if abs(rule.coordinate - coordinate) <= tolerance]
69
+ coverage = covered_interval_ratio(intervals, 0.0, height)
70
+ if coverage < 0.75:
71
+ continue
72
+ snapped = 0.0 if coordinate <= tolerance else width if width - coordinate <= tolerance else coordinate
73
+ positions.append(snapped)
74
+ coverages[snapped] = max(coverages.get(snapped, 0.0), coverage)
75
+ positions.extend([0.0, width])
76
+ return tuple(sorted(set(positions))), coverages
77
+
78
+
79
+ def _rectangle_edge_evidence(
80
+ table_input: NativeTableInput,
81
+ text: NativeTableText,
82
+ width: float,
83
+ height: float,
84
+ tolerance: float,
85
+ ) -> tuple[float, ...]:
86
+ """从表头单元格矩形和上下细条中提取可复现的列边界。"""
87
+
88
+ table_bbox = normalize_bbox(table_input.table_bbox)
89
+ if table_bbox is None:
90
+ return ()
91
+ angle = normalize_angle(table_input.angle)
92
+ raw_edges: list[float] = []
93
+ header_limit = min(0.30 * height, 3.0 * text.median_glyph_height)
94
+ thin_limit = max(1.5, 0.40 * text.median_glyph_height)
95
+ for rectangle in table_input.rectangles:
96
+ if rectangle.segment_count != 5 or not (rectangle.fill_visible or rectangle.stroke_visible):
97
+ continue
98
+ bbox = normalize_bbox(rectangle.bbox)
99
+ if bbox is None:
100
+ continue
101
+ local_bbox = page_bbox_to_table_local(bbox, table_bbox, angle)
102
+ if local_bbox is None:
103
+ continue
104
+ rect_width = local_bbox[2] - local_bbox[0]
105
+ rect_height = local_bbox[3] - local_bbox[1]
106
+ is_thin_rule = rect_height <= thin_limit and rect_width >= 2.0 * text.median_glyph_width
107
+ is_header_cell = (
108
+ local_bbox[1] <= header_limit
109
+ and rect_height <= 3.0 * text.median_glyph_height
110
+ and rect_width >= 2.0 * text.median_glyph_width
111
+ )
112
+ if not (is_thin_rule or is_header_cell):
113
+ continue
114
+ raw_edges.extend([max(0.0, local_bbox[0]), min(width, local_bbox[2])])
115
+
116
+ supported = [
117
+ coordinate
118
+ for coordinate, members in cluster_members(raw_edges, tolerance)
119
+ if len(members) >= 2 or coordinate <= tolerance or width - coordinate <= tolerance
120
+ ]
121
+ return tuple(sorted({0.0, width, *supported}))
122
+
123
+
124
+ def _canonical_edge_tracks(
125
+ positions: tuple[float, ...],
126
+ width: float,
127
+ tolerance: float,
128
+ ) -> tuple[float, ...]:
129
+ """折叠靠近表格外缘的重复矩形端点并返回严格递增轨道。"""
130
+
131
+ snapped = [0.0 if position <= tolerance else width if width - position <= tolerance else position for position in positions]
132
+ tracks = tuple(sorted(set(snapped)))
133
+ if any(current <= previous for previous, current in zip(tracks, tracks[1:])):
134
+ return ()
135
+ return tracks
136
+
137
+
138
+ def _longest_consecutive_run(indices: list[int]) -> tuple[int, ...]:
139
+ """返回整数索引列表中最长的连续区间。"""
140
+
141
+ if not indices:
142
+ return ()
143
+ runs: list[list[int]] = [[indices[0]]]
144
+ for index in indices[1:]:
145
+ if index == runs[-1][-1] + 1:
146
+ runs[-1].append(index)
147
+ else:
148
+ runs.append([index])
149
+ return tuple(max(runs, key=lambda run: (len(run), run[-1])))
150
+
151
+
152
+ def _infer_dense_layout(text: NativeTableText) -> _DenseLayout | None:
153
+ """从正文重复 token 数选择叶子列数和首条正文行。"""
154
+
155
+ counts = Counter(len(row.tokens) for row in text.rows if len(row.tokens) >= 2)
156
+ hypotheses: list[tuple[int, int, int, tuple[int, ...]]] = []
157
+ for count, occurrences in counts.items():
158
+ indices = [row.row_index for row in text.rows if len(row.tokens) == count]
159
+ run = _longest_consecutive_run(indices)
160
+ if len(run) < 2:
161
+ continue
162
+ hypotheses.append((len(run), occurrences, count, run))
163
+ if not hypotheses:
164
+ return None
165
+ _run_length, _occurrences, target_cols, run = max(hypotheses)
166
+ return _DenseLayout(
167
+ target_cols=target_cols,
168
+ body_start=run[0],
169
+ dense_row_indices=run,
170
+ )
171
+
172
+
173
+ def _infer_text_tracks(
174
+ text: NativeTableText,
175
+ width: float,
176
+ layout: _DenseLayout,
177
+ ) -> tuple[float, ...] | None:
178
+ """用正文相邻 token 空隙的中位位置推断叶子列边界。"""
179
+
180
+ dense_rows = [text.rows[index] for index in layout.dense_row_indices]
181
+ boundaries: list[float] = []
182
+ for col in range(layout.target_cols - 1):
183
+ midpoints = [(row.tokens[col].bbox[2] + row.tokens[col + 1].bbox[0]) / 2.0 for row in dense_rows]
184
+ boundary = float(statistics.median(midpoints))
185
+ if not 0.0 < boundary < width:
186
+ return None
187
+ boundaries.append(boundary)
188
+ tracks = (0.0, *boundaries, width)
189
+ if any(current <= previous for previous, current in zip(tracks, tracks[1:])):
190
+ return None
191
+ return tracks
192
+
193
+
194
+ def _row_glyph_occupancy(
195
+ row: NativeTableTextRow,
196
+ text: NativeTableText,
197
+ x_tracks: tuple[float, ...],
198
+ ) -> set[int]:
199
+ """按字符中心统计一条视觉行实际占用的叶子列。"""
200
+
201
+ glyph_by_id = {glyph.glyph_id: glyph for glyph in text.glyphs}
202
+ occupied: set[int] = set()
203
+ for glyph_id in row.glyph_ids:
204
+ glyph = glyph_by_id[glyph_id]
205
+ center = (glyph.bbox[0] + glyph.bbox[2]) / 2.0
206
+ col = next(
207
+ (index for index, (left, right) in enumerate(zip(x_tracks, x_tracks[1:])) if left <= center <= right),
208
+ None,
209
+ )
210
+ if col is not None:
211
+ occupied.add(col)
212
+ return occupied
213
+
214
+
215
+ def _track_support(
216
+ text: NativeTableText,
217
+ x_tracks: tuple[float, ...],
218
+ body_start: int,
219
+ ) -> tuple[float, float, tuple[set[int], ...]] | None:
220
+ """校验正文行、关键列和各叶子列的重复占用支持。"""
221
+
222
+ body_rows = text.rows[body_start:]
223
+ cols = len(x_tracks) - 1
224
+ if len(body_rows) < 2 or cols < 2:
225
+ return None
226
+ occupancies = tuple(_row_glyph_occupancy(row, text, x_tracks) for row in body_rows)
227
+ minimum_dense_cols = max(2, math.ceil(MIN_COLUMN_SUPPORT * cols))
228
+ if any(len(occupancy) < minimum_dense_cols for occupancy in occupancies):
229
+ return None
230
+ first_columns = [min(occupancy) for occupancy in occupancies if occupancy]
231
+ if not first_columns:
232
+ return None
233
+ key_col = Counter(first_columns).most_common(1)[0][0]
234
+ if sum(key_col in occupancy for occupancy in occupancies) / len(occupancies) < MIN_COLUMN_SUPPORT:
235
+ return None
236
+ supports = [sum(col in occupancy for occupancy in occupancies) / len(occupancies) for col in range(cols)]
237
+ minimum_support = min(supports)
238
+ overall_support = float(statistics.mean(supports))
239
+ if minimum_support < MIN_COLUMN_SUPPORT or overall_support < MIN_OVERALL_ANCHOR_SUPPORT:
240
+ return None
241
+ return minimum_support, overall_support, occupancies
242
+
243
+
244
+ def _nearest_physical_boundaries(
245
+ x_tracks: tuple[float, ...],
246
+ physical_positions: tuple[float, ...],
247
+ tolerance: float,
248
+ ) -> frozenset[int]:
249
+ """标记能被独立 drawing 或矩形边缘支持的内部列边界。"""
250
+
251
+ return frozenset(
252
+ index
253
+ for index, coordinate in enumerate(x_tracks[1:-1], start=1)
254
+ if any(abs(coordinate - physical) <= tolerance for physical in physical_positions)
255
+ )
256
+
257
+
258
+ def _infer_y_tracks(
259
+ text: NativeTableText,
260
+ rules: tuple[_LocalRule, ...],
261
+ height: float,
262
+ ) -> tuple[float, ...] | None:
263
+ """以视觉行中心中点为基础并优先吸附相邻行间横线。"""
264
+
265
+ if len(text.rows) < 2:
266
+ return None
267
+ tracks: list[float] = [0.0]
268
+ horizontal_rules = [rule for rule in rules if rule.orientation == "horizontal"]
269
+ for previous, current in zip(text.rows, text.rows[1:]):
270
+ candidates = [rule for rule in horizontal_rules if previous.bbox[3] - 0.5 <= rule.coordinate <= current.bbox[1] + 0.5]
271
+ if candidates:
272
+ boundary = max(candidates, key=lambda rule: rule.end - rule.start).coordinate
273
+ else:
274
+ previous_center = (previous.bbox[1] + previous.bbox[3]) / 2.0
275
+ current_center = (current.bbox[1] + current.bbox[3]) / 2.0
276
+ boundary = (previous_center + current_center) / 2.0
277
+ tracks.append(boundary)
278
+ tracks.append(height)
279
+ if any(current <= previous for previous, current in zip(tracks, tracks[1:])):
280
+ return None
281
+ return tuple(tracks)
282
+
283
+
284
+ def _horizontal_separator_coverage(
285
+ rules: tuple[_LocalRule, ...],
286
+ boundary: float,
287
+ left: float,
288
+ right: float,
289
+ tolerance: float,
290
+ ) -> float:
291
+ """计算指定表头行边界在一个叶子列范围内的横线覆盖率。"""
292
+
293
+ intervals = [
294
+ (rule.start, rule.end)
295
+ for rule in rules
296
+ if rule.orientation == "horizontal" and abs(rule.coordinate - boundary) <= tolerance
297
+ ]
298
+ return covered_interval_ratio(intervals, left, right)
299
+
300
+
301
+ def _row_token_columns(
302
+ row: NativeTableTextRow,
303
+ x_tracks: tuple[float, ...],
304
+ ) -> list[int]:
305
+ """把一行粗 token 的中心映射到叶子列。"""
306
+
307
+ output: list[int] = []
308
+ for token in row.tokens:
309
+ center = (token.bbox[0] + token.bbox[2]) / 2.0
310
+ col = next(
311
+ (index for index, (left, right) in enumerate(zip(x_tracks, x_tracks[1:])) if left <= center <= right),
312
+ None,
313
+ )
314
+ if col is not None:
315
+ output.append(col)
316
+ return output
317
+
318
+
319
+ def _two_level_header_specs(
320
+ text: NativeTableText,
321
+ x_tracks: tuple[float, ...],
322
+ y_tracks: tuple[float, ...],
323
+ rules: tuple[_LocalRule, ...],
324
+ tolerance: float,
325
+ ) -> tuple[GridCellSpec, ...] | None:
326
+ """用局部横线和上下层文本恢复两层表头的 rowspan/colspan。"""
327
+
328
+ cols = len(x_tracks) - 1
329
+ boundary = y_tracks[1]
330
+ coverages = [
331
+ _horizontal_separator_coverage(
332
+ rules,
333
+ boundary,
334
+ x_tracks[col],
335
+ x_tracks[col + 1],
336
+ tolerance,
337
+ )
338
+ for col in range(cols)
339
+ ]
340
+ if any(0.20 < coverage < 0.80 for coverage in coverages):
341
+ return None
342
+ absent_cols = {col for col, coverage in enumerate(coverages) if coverage <= 0.20}
343
+ present_cols = set(range(cols)) - absent_cols
344
+ specs: list[GridCellSpec] = [
345
+ GridCellSpec(
346
+ row=0,
347
+ col=col,
348
+ rowspan=2,
349
+ colspan=1,
350
+ bbox=(x_tracks[col], y_tracks[0], x_tracks[col + 1], y_tracks[2]),
351
+ )
352
+ for col in sorted(absent_cols)
353
+ ]
354
+
355
+ child_occupied = _row_glyph_occupancy(text.rows[1], text, x_tracks).intersection(present_cols)
356
+ top_tokens = [
357
+ (token, col)
358
+ for token, col in zip(
359
+ text.rows[0].tokens,
360
+ _row_token_columns(text.rows[0], x_tracks),
361
+ strict=False,
362
+ )
363
+ if col in present_cols
364
+ ]
365
+ assigned_groups: dict[int, list[int]] = {index: [] for index in range(len(top_tokens))}
366
+ for col in sorted(child_occupied):
367
+ col_center = (x_tracks[col] + x_tracks[col + 1]) / 2.0
368
+ if not top_tokens:
369
+ return None
370
+ owner = min(
371
+ range(len(top_tokens)),
372
+ key=lambda index: abs(col_center - (top_tokens[index][0].bbox[0] + top_tokens[index][0].bbox[2]) / 2.0),
373
+ )
374
+ assigned_groups[owner].append(col)
375
+
376
+ top_covered: set[int] = set()
377
+ for index, (token, _token_col) in enumerate(top_tokens):
378
+ group = assigned_groups[index]
379
+ if not group or group != list(range(group[0], group[-1] + 1)):
380
+ return None
381
+ token_center = (token.bbox[0] + token.bbox[2]) / 2.0
382
+ if not x_tracks[group[0]] <= token_center <= x_tracks[group[-1] + 1]:
383
+ return None
384
+ specs.append(
385
+ GridCellSpec(
386
+ row=0,
387
+ col=group[0],
388
+ rowspan=1,
389
+ colspan=group[-1] - group[0] + 1,
390
+ bbox=(x_tracks[group[0]], y_tracks[0], x_tracks[group[-1] + 1], y_tracks[1]),
391
+ )
392
+ )
393
+ top_covered.update(group)
394
+
395
+ for col in sorted(present_cols - top_covered):
396
+ specs.append(
397
+ GridCellSpec(
398
+ row=0,
399
+ col=col,
400
+ rowspan=1,
401
+ colspan=1,
402
+ bbox=(x_tracks[col], y_tracks[0], x_tracks[col + 1], y_tracks[1]),
403
+ )
404
+ )
405
+ for col in sorted(present_cols):
406
+ specs.append(
407
+ GridCellSpec(
408
+ row=1,
409
+ col=col,
410
+ rowspan=1,
411
+ colspan=1,
412
+ bbox=(x_tracks[col], y_tracks[1], x_tracks[col + 1], y_tracks[2]),
413
+ )
414
+ )
415
+ return tuple(specs)
416
+
417
+
418
+ def _build_sparse_specs(
419
+ text: NativeTableText,
420
+ x_tracks: tuple[float, ...],
421
+ y_tracks: tuple[float, ...],
422
+ body_start: int,
423
+ rules: tuple[_LocalRule, ...],
424
+ tolerance: float,
425
+ ) -> tuple[GridCellSpec, ...] | None:
426
+ """构造完整少线网格,并仅在两层表头中推断合并格。"""
427
+
428
+ rows = len(y_tracks) - 1
429
+ cols = len(x_tracks) - 1
430
+ if body_start > MAX_HEADER_ROWS:
431
+ return None
432
+ specs: list[GridCellSpec] = []
433
+ if body_start == 2:
434
+ header_specs = _two_level_header_specs(
435
+ text,
436
+ x_tracks,
437
+ y_tracks,
438
+ rules,
439
+ tolerance,
440
+ )
441
+ if header_specs is None:
442
+ return None
443
+ specs.extend(header_specs)
444
+ else:
445
+ for row in range(body_start):
446
+ for col in range(cols):
447
+ specs.append(
448
+ GridCellSpec(
449
+ row=row,
450
+ col=col,
451
+ rowspan=1,
452
+ colspan=1,
453
+ bbox=(x_tracks[col], y_tracks[row], x_tracks[col + 1], y_tracks[row + 1]),
454
+ )
455
+ )
456
+ for row in range(body_start, rows):
457
+ for col in range(cols):
458
+ specs.append(
459
+ GridCellSpec(
460
+ row=row,
461
+ col=col,
462
+ rowspan=1,
463
+ colspan=1,
464
+ bbox=(x_tracks[col], y_tracks[row], x_tracks[col + 1], y_tracks[row + 1]),
465
+ )
466
+ )
467
+ return tuple(specs)
468
+
469
+
470
+ def _spec_owner_grid(
471
+ rows: int,
472
+ cols: int,
473
+ specs: tuple[GridCellSpec, ...],
474
+ ) -> list[list[int]]:
475
+ """把逻辑单元格展开为原子格到 spec 下标的映射。"""
476
+
477
+ owners = [[-1 for _ in range(cols)] for _ in range(rows)]
478
+ for index, spec in enumerate(specs):
479
+ for row in range(spec.row, spec.row + spec.rowspan):
480
+ for col in range(spec.col, spec.col + spec.colspan):
481
+ owners[row][col] = index
482
+ return owners
483
+
484
+
485
+ def _validate_token_splits(
486
+ text: NativeTableText,
487
+ x_tracks: tuple[float, ...],
488
+ specs: tuple[GridCellSpec, ...],
489
+ physical_boundaries: frozenset[int],
490
+ ) -> tuple[bool, int]:
491
+ """只允许被强物理边界证明且不横切字符的粗 token 跨格。"""
492
+
493
+ rows = len(text.rows)
494
+ cols = len(x_tracks) - 1
495
+ owners = _spec_owner_grid(rows, cols, specs)
496
+ glyph_by_id = {glyph.glyph_id: glyph for glyph in text.glyphs}
497
+ edge_tolerance = max(0.25, 0.05 * text.median_glyph_width)
498
+ justified_splits = 0
499
+ for row in text.rows:
500
+ for token in row.tokens:
501
+ for boundary_index, boundary in enumerate(x_tracks[1:-1], start=1):
502
+ if owners[row.row_index][boundary_index - 1] == owners[row.row_index][boundary_index]:
503
+ continue
504
+ if not token.bbox[0] + edge_tolerance < boundary < token.bbox[2] - edge_tolerance:
505
+ continue
506
+ if boundary_index not in physical_boundaries:
507
+ return False, justified_splits
508
+ if any(
509
+ glyph_by_id[glyph_id].bbox[0] + edge_tolerance < boundary < glyph_by_id[glyph_id].bbox[2] - edge_tolerance
510
+ for glyph_id in token.glyph_ids
511
+ ):
512
+ return False, justified_splits
513
+ justified_splits += 1
514
+ return True, justified_splits
515
+
516
+
517
+ def _build_hypothesis_candidate(
518
+ table_input: NativeTableInput,
519
+ text: NativeTableText,
520
+ rules: tuple[_LocalRule, ...],
521
+ hypothesis: _TrackHypothesis,
522
+ height: float,
523
+ diagnostics: dict[str, Any] | None,
524
+ ) -> NativeTableCandidate | None:
525
+ """把一组少线轨道恢复为候选并执行全部高置信硬门。"""
526
+
527
+ x_tracks = hypothesis.x_tracks
528
+ y_tracks = _infer_y_tracks(text, rules, height)
529
+ if y_tracks is None:
530
+ if diagnostics is not None:
531
+ diagnostics["first_rejection_gate"] = "row_tracks"
532
+ return None
533
+ support = _track_support(text, x_tracks, hypothesis.body_start)
534
+ if support is None:
535
+ if diagnostics is not None:
536
+ diagnostics["first_rejection_gate"] = "anchor_support"
537
+ return None
538
+ minimum_support, overall_support, occupancies = support
539
+ tolerance = max(1.0, 0.25 * text.median_glyph_height)
540
+ specs = _build_sparse_specs(
541
+ text,
542
+ x_tracks,
543
+ y_tracks,
544
+ hypothesis.body_start,
545
+ rules,
546
+ tolerance,
547
+ )
548
+ if specs is None:
549
+ if diagnostics is not None:
550
+ diagnostics["first_rejection_gate"] = "header_topology"
551
+ return None
552
+ token_splits_valid, justified_splits = _validate_token_splits(
553
+ text,
554
+ x_tracks,
555
+ specs,
556
+ hypothesis.physical_boundaries,
557
+ )
558
+ if not token_splits_valid:
559
+ if diagnostics is not None:
560
+ diagnostics["first_rejection_gate"] = "token_split"
561
+ return None
562
+
563
+ candidate_diagnostics: dict[str, object] = {}
564
+ candidate = build_candidate(
565
+ source="sparse_hybrid",
566
+ rows=len(y_tracks) - 1,
567
+ cols=len(x_tracks) - 1,
568
+ specs=specs,
569
+ text=text,
570
+ structure_support=hypothesis.reliability,
571
+ row_stability=1.0,
572
+ column_stability=min(1.0, overall_support),
573
+ issues=(
574
+ f"evidence={hypothesis.evidence}",
575
+ f"body_start={hypothesis.body_start}",
576
+ f"minimum_column_support={minimum_support:.4f}",
577
+ f"overall_anchor_support={overall_support:.4f}",
578
+ f"physical_boundaries={len(hypothesis.physical_boundaries)}",
579
+ f"physically_justified_token_splits={justified_splits}",
580
+ ),
581
+ use_grid_index=True,
582
+ diagnostics=candidate_diagnostics,
583
+ )
584
+ if candidate is None:
585
+ if diagnostics is not None:
586
+ diagnostics["first_rejection_gate"] = candidate_diagnostics.get(
587
+ "candidate_rejection_gate",
588
+ "candidate_hard_gate",
589
+ )
590
+ return None
591
+ ambiguous_ratio = float(candidate_diagnostics.get("ambiguous_glyph_ratio", 1.0))
592
+ if (
593
+ candidate.text_capture < 1.0
594
+ or candidate.order_consistency < 1.0
595
+ or ambiguous_ratio > 0.0
596
+ or candidate.score < MIN_SPARSE_RELIABILITY
597
+ ):
598
+ if diagnostics is not None:
599
+ diagnostics["first_rejection_gate"] = "verified_integrity"
600
+ return None
601
+ if diagnostics is not None:
602
+ diagnostics.update(
603
+ {
604
+ "first_rejection_gate": None,
605
+ "grid": {"rows": candidate.rows, "cols": candidate.cols},
606
+ "body_start": hypothesis.body_start,
607
+ "x_tracks": list(x_tracks),
608
+ "physical_boundaries": sorted(hypothesis.physical_boundaries),
609
+ "minimum_column_support": minimum_support,
610
+ "overall_anchor_support": overall_support,
611
+ "body_occupancies": [sorted(occupancy) for occupancy in occupancies],
612
+ "physically_justified_token_splits": justified_splits,
613
+ "score": candidate.score,
614
+ }
615
+ )
616
+ return candidate
617
+
618
+
619
+ def _build_track_hypotheses(
620
+ table_input: NativeTableInput,
621
+ text: NativeTableText,
622
+ rules: tuple[_LocalRule, ...],
623
+ width: float,
624
+ height: float,
625
+ ) -> tuple[_TrackHypothesis, ...]:
626
+ """构造有限的文本轨和强竖线轨假设并消除同拓扑重复。"""
627
+
628
+ layout = _infer_dense_layout(text)
629
+ if layout is None:
630
+ return ()
631
+ tolerance = max(1.0, 0.25 * text.median_glyph_height)
632
+ vertical_tracks, vertical_coverages = _vertical_track_evidence(
633
+ rules,
634
+ width,
635
+ height,
636
+ tolerance,
637
+ )
638
+ rect_positions = _rectangle_edge_evidence(
639
+ table_input,
640
+ text,
641
+ width,
642
+ height,
643
+ tolerance,
644
+ )
645
+ physical_positions = tuple(sorted({*vertical_tracks, *rect_positions}))
646
+ rect_tracks = _canonical_edge_tracks(rect_positions, width, tolerance)
647
+ text_tracks = _infer_text_tracks(text, width, layout)
648
+ hypotheses: list[_TrackHypothesis] = []
649
+
650
+ physical_cols = len(vertical_tracks) - 1
651
+ if (
652
+ physical_cols >= 2
653
+ and physical_cols in {layout.target_cols, layout.target_cols + 1}
654
+ and _track_support(text, vertical_tracks, layout.body_start) is not None
655
+ ):
656
+ internal_coverages = [
657
+ coverage for coordinate, coverage in vertical_coverages.items() if tolerance < coordinate < width - tolerance
658
+ ]
659
+ reliability = min(internal_coverages, default=1.0)
660
+ if physical_cols == layout.target_cols:
661
+ reliability = 1.0
662
+ hypotheses.append(
663
+ _TrackHypothesis(
664
+ evidence="vertical_text",
665
+ x_tracks=vertical_tracks,
666
+ physical_boundaries=frozenset(range(1, len(vertical_tracks) - 1)),
667
+ body_start=layout.body_start,
668
+ reliability=min(1.0, reliability),
669
+ )
670
+ )
671
+
672
+ rect_cols = len(rect_tracks) - 1
673
+ if rect_cols == layout.target_cols and rect_cols >= 2 and _track_support(text, rect_tracks, layout.body_start) is not None:
674
+ hypotheses.append(
675
+ _TrackHypothesis(
676
+ evidence="rect_text",
677
+ x_tracks=rect_tracks,
678
+ physical_boundaries=frozenset(range(1, len(rect_tracks) - 1)),
679
+ body_start=layout.body_start,
680
+ reliability=1.0,
681
+ )
682
+ )
683
+
684
+ prefer_physical = bool(hypotheses)
685
+ if text_tracks is not None and not prefer_physical:
686
+ hypotheses.append(
687
+ _TrackHypothesis(
688
+ evidence="text_network",
689
+ x_tracks=text_tracks,
690
+ physical_boundaries=_nearest_physical_boundaries(
691
+ text_tracks,
692
+ physical_positions,
693
+ tolerance,
694
+ ),
695
+ body_start=layout.body_start,
696
+ reliability=1.0,
697
+ )
698
+ )
699
+
700
+ deduplicated: dict[tuple[int, tuple[int, ...]], _TrackHypothesis] = {}
701
+ for hypothesis in hypotheses[:MAX_SPARSE_HYPOTHESES]:
702
+ signature = (
703
+ len(hypothesis.x_tracks),
704
+ tuple(round(track / max(tolerance, 0.1)) for track in hypothesis.x_tracks),
705
+ )
706
+ existing = deduplicated.get(signature)
707
+ if existing is None or hypothesis.reliability > existing.reliability:
708
+ deduplicated[signature] = hypothesis
709
+ return tuple(deduplicated.values())
710
+
711
+
712
+ def build_sparse_hybrid_candidates(
713
+ table_input: NativeTableInput,
714
+ text: NativeTableText,
715
+ diagnostics: list[dict[str, Any]] | None = None,
716
+ ) -> list[NativeTableCandidate]:
717
+ """生成只在矢量网格失败后参与仲裁的高置信少线候选。"""
718
+
719
+ table_bbox = normalize_bbox(table_input.table_bbox)
720
+ if table_bbox is None:
721
+ return []
722
+ width, height = table_local_size(table_bbox, normalize_angle(table_input.angle))
723
+ rules = _local_rules(table_input, width, height)
724
+ long_rules = _long_horizontal_rules(rules, width)
725
+ tolerance = max(1.0, 0.25 * text.median_glyph_height)
726
+ vertical_tracks, _coverages = _vertical_track_evidence(
727
+ rules,
728
+ width,
729
+ height,
730
+ tolerance,
731
+ )
732
+ if len(long_rules) < 2 and len(vertical_tracks) < 3:
733
+ if diagnostics is not None:
734
+ diagnostics.append(
735
+ {
736
+ "source": "sparse_hybrid",
737
+ "first_rejection_gate": "physical_sparse_evidence",
738
+ "long_horizontal_rules": len(long_rules),
739
+ "vertical_tracks": len(vertical_tracks),
740
+ }
741
+ )
742
+ return []
743
+
744
+ hypotheses = _build_track_hypotheses(
745
+ table_input,
746
+ text,
747
+ rules,
748
+ width,
749
+ height,
750
+ )
751
+ candidates: list[NativeTableCandidate] = []
752
+ for hypothesis in hypotheses:
753
+ record: dict[str, Any] | None = (
754
+ {
755
+ "source": "sparse_hybrid",
756
+ "evidence": hypothesis.evidence,
757
+ "long_horizontal_rules": len(long_rules),
758
+ }
759
+ if diagnostics is not None
760
+ else None
761
+ )
762
+ candidate = _build_hypothesis_candidate(
763
+ table_input,
764
+ text,
765
+ rules,
766
+ hypothesis,
767
+ height,
768
+ record,
769
+ )
770
+ if diagnostics is not None and record is not None:
771
+ diagnostics.append(record)
772
+ if candidate is not None:
773
+ candidates.append(candidate)
774
+
775
+ topologies = {candidate.topology for candidate in candidates}
776
+ if len(topologies) > 1:
777
+ if diagnostics is not None:
778
+ diagnostics.append(
779
+ {
780
+ "source": "sparse_hybrid",
781
+ "first_rejection_gate": "topology_ambiguity",
782
+ "topology_count": len(topologies),
783
+ }
784
+ )
785
+ return []
786
+ return candidates[:1]
787
+
788
+
789
+ def diagnose_sparse_hybrid_candidate_builds(
790
+ table_input: NativeTableInput,
791
+ text: NativeTableText,
792
+ ) -> tuple[dict[str, Any], ...]:
793
+ """重放少线候选构造并返回不进入用户结果的诊断。"""
794
+
795
+ diagnostics: list[dict[str, Any]] = []
796
+ build_sparse_hybrid_candidates(
797
+ table_input,
798
+ text,
799
+ diagnostics=diagnostics,
800
+ )
801
+ return tuple(diagnostics)
802
+
803
+
804
+ __all__ = ["build_sparse_hybrid_candidates"]