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,1122 @@
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, NativeTableGlyph, 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_MULTILINE_RELIABILITY = 0.98
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class _LocalRectangle:
21
+ """保存正向表格局部坐标中的矩形证据。"""
22
+
23
+ bbox: tuple[float, float, float, float]
24
+ fill_visible: bool
25
+ stroke_visible: bool
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class _ColumnHypothesis:
30
+ """保存少线多行候选的一组列轨与来源证据。"""
31
+
32
+ evidence: str
33
+ x_tracks: tuple[float, ...]
34
+ physical_boundaries: frozenset[int]
35
+ filled_band_count: int
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class _LogicalRow:
40
+ """保存由一条或多条视觉基线组成的逻辑正文行。"""
41
+
42
+ visual_indices: tuple[int, ...]
43
+ top: float
44
+ bottom: float
45
+
46
+
47
+ def _local_rectangles(
48
+ table_input: NativeTableInput,
49
+ width: float,
50
+ height: float,
51
+ ) -> tuple[_LocalRectangle, ...]:
52
+ """把相交矩形转换为局部坐标并裁剪到表格范围。"""
53
+
54
+ table_bbox = normalize_bbox(table_input.table_bbox)
55
+ if table_bbox is None:
56
+ return ()
57
+ angle = normalize_angle(table_input.angle)
58
+ output: list[_LocalRectangle] = []
59
+ for rectangle in table_input.rectangles:
60
+ if rectangle.segment_count != 5 or not (rectangle.fill_visible or rectangle.stroke_visible):
61
+ continue
62
+ bbox = normalize_bbox(rectangle.bbox)
63
+ if bbox is None:
64
+ continue
65
+ local_bbox = page_bbox_to_table_local(bbox, table_bbox, angle)
66
+ if local_bbox is None:
67
+ continue
68
+ clipped = (
69
+ max(0.0, local_bbox[0]),
70
+ max(0.0, local_bbox[1]),
71
+ min(width, local_bbox[2]),
72
+ min(height, local_bbox[3]),
73
+ )
74
+ if clipped[2] <= clipped[0] or clipped[3] <= clipped[1]:
75
+ continue
76
+ output.append(
77
+ _LocalRectangle(
78
+ bbox=clipped,
79
+ fill_visible=rectangle.fill_visible,
80
+ stroke_visible=rectangle.stroke_visible,
81
+ )
82
+ )
83
+ return tuple(output)
84
+
85
+
86
+ def _row_occupancy(
87
+ row: NativeTableTextRow,
88
+ glyph_by_id: dict[int, NativeTableGlyph],
89
+ x_tracks: tuple[float, ...],
90
+ ) -> set[int]:
91
+ """按字符中心统计一条视觉行占用的叶子列。"""
92
+
93
+ occupied: set[int] = set()
94
+ for glyph_id in row.glyph_ids:
95
+ glyph = glyph_by_id[glyph_id]
96
+ center = (glyph.bbox[0] + glyph.bbox[2]) / 2.0
97
+ for col, (left, right) in enumerate(zip(x_tracks, x_tracks[1:])):
98
+ if left <= center <= right:
99
+ occupied.add(col)
100
+ break
101
+ return occupied
102
+
103
+
104
+ def _infer_target_columns(text: NativeTableText) -> int | None:
105
+ """从重复的最大 token 数推断叶子列数。"""
106
+
107
+ counts = Counter(len(row.tokens) for row in text.rows if 2 <= len(row.tokens) <= 20)
108
+ candidates = [count for count, occurrences in counts.items() if occurrences >= 2]
109
+ return max(candidates) if candidates else None
110
+
111
+
112
+ def _infer_text_tracks(
113
+ text: NativeTableText,
114
+ width: float,
115
+ target_cols: int,
116
+ ) -> tuple[float, ...] | None:
117
+ """从完整锚点行的相邻 token 空隙恢复文本列轨。"""
118
+
119
+ anchor_rows = [row for row in text.rows if len(row.tokens) == target_cols]
120
+ if len(anchor_rows) < 2:
121
+ return None
122
+ boundaries: list[float] = []
123
+ for col in range(target_cols - 1):
124
+ left_edges = [row.tokens[col].bbox[2] for row in anchor_rows]
125
+ right_edges = [row.tokens[col + 1].bbox[0] for row in anchor_rows]
126
+ global_left = max(left_edges)
127
+ global_right = min(right_edges)
128
+ if global_left < global_right:
129
+ boundary = (global_left + global_right) / 2.0
130
+ else:
131
+ midpoints = [(left + right) / 2.0 for left, right in zip(left_edges, right_edges, strict=True)]
132
+ boundary = float(statistics.median(midpoints))
133
+ if not 0.0 < boundary < width:
134
+ return None
135
+ boundaries.append(boundary)
136
+ tracks = _refine_text_tracks(
137
+ text,
138
+ (0.0, *boundaries, width),
139
+ )
140
+ minimum_width = max(1.0, 0.50 * text.median_glyph_width)
141
+ if any(current - previous < minimum_width for previous, current in zip(tracks, tracks[1:])):
142
+ return None
143
+ return tracks
144
+
145
+
146
+ def _refine_text_tracks(
147
+ text: NativeTableText,
148
+ tracks: tuple[float, ...],
149
+ ) -> tuple[float, ...]:
150
+ """用全部简单 token 的外缘扩展文本列间空白走廊。"""
151
+
152
+ refined = list(tracks)
153
+ for boundary_index in range(1, len(tracks) - 1):
154
+ boundary = refined[boundary_index]
155
+ left_limits: list[float] = []
156
+ right_limits: list[float] = []
157
+ for row in text.rows:
158
+ for token in row.tokens:
159
+ crossed_boundaries = sum(token.bbox[0] < item < token.bbox[2] for item in tracks[1:-1])
160
+ if crossed_boundaries > 1:
161
+ continue
162
+ center = (token.bbox[0] + token.bbox[2]) / 2.0
163
+ if center < boundary:
164
+ left_limits.append(token.bbox[2])
165
+ elif center > boundary:
166
+ right_limits.append(token.bbox[0])
167
+ if not left_limits or not right_limits:
168
+ continue
169
+ lower = max(left_limits)
170
+ upper = min(right_limits)
171
+ if refined[boundary_index - 1] < lower < upper < refined[boundary_index + 1]:
172
+ refined[boundary_index] = (lower + upper) / 2.0
173
+ return tuple(refined)
174
+
175
+
176
+ def _rectangle_tracks(
177
+ rectangles: tuple[_LocalRectangle, ...],
178
+ width: float,
179
+ tolerance: float,
180
+ outer_tolerance: float,
181
+ ) -> tuple[float, ...]:
182
+ """从重复矩形端点恢复列轨并去除一次性装饰边缘。"""
183
+
184
+ edges = [coordinate for rectangle in rectangles for coordinate in (rectangle.bbox[0], rectangle.bbox[2])]
185
+ positions: list[float] = [0.0, width]
186
+ for coordinate, members in cluster_members(edges, tolerance):
187
+ if len(members) < 2:
188
+ continue
189
+ snapped = 0.0 if coordinate <= outer_tolerance else width if width - coordinate <= outer_tolerance else coordinate
190
+ positions.append(snapped)
191
+ tracks = tuple(sorted(set(positions)))
192
+ if any(current <= previous for previous, current in zip(tracks, tracks[1:])):
193
+ return ()
194
+ return tracks
195
+
196
+
197
+ def _filled_band_count(
198
+ rectangles: tuple[_LocalRectangle, ...],
199
+ width: float,
200
+ median_height: float,
201
+ tolerance: float,
202
+ ) -> int:
203
+ """统计能覆盖多列的重复填充行带数量。"""
204
+
205
+ bands: list[tuple[float, float]] = []
206
+ for rectangle in rectangles:
207
+ if not rectangle.fill_visible:
208
+ continue
209
+ left, top, right, bottom = rectangle.bbox
210
+ if bottom - top < 0.75 * median_height or right - left < 0.08 * width:
211
+ continue
212
+ bands.append((top, bottom))
213
+ clustered = cluster_members(
214
+ [(top + bottom) / 2.0 for top, bottom in bands],
215
+ max(tolerance, 0.50 * median_height),
216
+ )
217
+ return len(clustered)
218
+
219
+
220
+ def _build_column_hypothesis(
221
+ text: NativeTableText,
222
+ width: float,
223
+ rules: tuple[_LocalRule, ...],
224
+ rectangles: tuple[_LocalRectangle, ...],
225
+ diagnostics: dict[str, Any] | None,
226
+ ) -> _ColumnHypothesis | None:
227
+ """融合矩形端点与文本空白选择唯一列轨假设。"""
228
+
229
+ target_cols = _infer_target_columns(text)
230
+ if target_cols is None:
231
+ if diagnostics is not None:
232
+ diagnostics["first_rejection_gate"] = "column_count"
233
+ return None
234
+ text_tracks = _infer_text_tracks(text, width, target_cols)
235
+ tolerance = max(1.0, 0.25 * text.median_glyph_height)
236
+ rect_tracks = _rectangle_tracks(
237
+ rectangles,
238
+ width,
239
+ tolerance,
240
+ max(tolerance, 1.50 * text.median_glyph_height),
241
+ )
242
+ physical_boundaries: frozenset[int] = frozenset()
243
+ tracks = text_tracks
244
+ if len(rect_tracks) == target_cols + 1:
245
+ tracks = rect_tracks
246
+ physical_boundaries = frozenset(range(1, target_cols))
247
+ if tracks is None:
248
+ if diagnostics is not None:
249
+ diagnostics["first_rejection_gate"] = "column_tracks"
250
+ return None
251
+
252
+ band_count = _filled_band_count(
253
+ rectangles,
254
+ width,
255
+ text.median_glyph_height,
256
+ tolerance,
257
+ )
258
+ internal_full_rules = [
259
+ rule
260
+ for rule in rules
261
+ if rule.orientation == "horizontal"
262
+ and rule.end - rule.start >= 0.90 * width
263
+ and 0.01 * max((row.bbox[3] for row in text.rows), default=0.0)
264
+ < rule.coordinate
265
+ < 0.99 * max((row.bbox[3] for row in text.rows), default=0.0)
266
+ ]
267
+ if band_count >= 2 and physical_boundaries:
268
+ evidence = "filled_record"
269
+ elif internal_full_rules:
270
+ evidence = "rule_band"
271
+ else:
272
+ evidence = "keyed_record"
273
+ if diagnostics is not None:
274
+ diagnostics.update(
275
+ {
276
+ "target_cols": target_cols,
277
+ "x_tracks": list(tracks),
278
+ "physical_boundaries": sorted(physical_boundaries),
279
+ "filled_band_count": band_count,
280
+ "evidence": evidence,
281
+ }
282
+ )
283
+ return _ColumnHypothesis(
284
+ evidence=evidence,
285
+ x_tracks=tracks,
286
+ physical_boundaries=physical_boundaries,
287
+ filled_band_count=band_count,
288
+ )
289
+
290
+
291
+ def _internal_full_rules(
292
+ rules: tuple[_LocalRule, ...],
293
+ width: float,
294
+ height: float,
295
+ ) -> tuple[_LocalRule, ...]:
296
+ """返回排除上下外框后的长横线。"""
297
+
298
+ return tuple(
299
+ rule
300
+ for rule in rules
301
+ if rule.orientation == "horizontal"
302
+ and rule.end - rule.start >= 0.90 * width
303
+ and 0.01 * height < rule.coordinate < 0.99 * height
304
+ )
305
+
306
+
307
+ def _infer_header_boundary(
308
+ text: NativeTableText,
309
+ x_tracks: tuple[float, ...],
310
+ rules: tuple[_LocalRule, ...],
311
+ width: float,
312
+ height: float,
313
+ evidence: str,
314
+ ) -> float | None:
315
+ """用首条正文锚点和最后一条表头长线确定表头底边。"""
316
+
317
+ if len(text.rows) < 3:
318
+ return None
319
+ target_cols = len(x_tracks) - 1
320
+ body_hint = next(
321
+ (row for row in text.rows[1:] if len(row.tokens) == target_cols),
322
+ text.rows[1],
323
+ )
324
+ body_center = (body_hint.bbox[1] + body_hint.bbox[3]) / 2.0
325
+ candidates = [rule.coordinate for rule in _internal_full_rules(rules, width, height) if rule.coordinate < body_center]
326
+ if candidates:
327
+ boundary = max(candidates)
328
+ else:
329
+ if evidence in {"filled_record", "keyed_record"}:
330
+ first = text.rows[0]
331
+ second = text.rows[1]
332
+ return ((first.bbox[1] + first.bbox[3]) / 2.0 + (second.bbox[1] + second.bbox[3]) / 2.0) / 2.0
333
+ previous_rows = [row for row in text.rows if row.row_index < body_hint.row_index]
334
+ if not previous_rows:
335
+ return None
336
+ previous = previous_rows[-1]
337
+ boundary = ((previous.bbox[1] + previous.bbox[3]) / 2.0 + body_center) / 2.0
338
+ if not text.rows[0].bbox[3] - 0.5 <= boundary <= text.rows[-1].bbox[1] + 0.5:
339
+ return None
340
+ return boundary
341
+
342
+
343
+ def _choose_key_column(
344
+ occupancies: list[set[int]],
345
+ cols: int,
346
+ ) -> tuple[int, int]:
347
+ """选择能重复标记逻辑记录起点的最左稳定关键列。"""
348
+
349
+ stats: list[tuple[int, int, int]] = []
350
+ for col in range(cols):
351
+ flags = [col in occupancy for occupancy in occupancies]
352
+ runs = 0
353
+ previous = False
354
+ for flag in flags:
355
+ if flag and not previous:
356
+ runs += 1
357
+ previous = flag
358
+ occupied = sum(flags)
359
+ stats.append((col, runs, occupied))
360
+ if stats[0][2] >= math.ceil(0.80 * len(occupancies)):
361
+ return 0, stats[0][1]
362
+ repeated = [item for item in stats if item[1] >= 3]
363
+ if repeated:
364
+ maximum_occupied = max(item[2] for item in stats)
365
+ dense_repeated = [item for item in repeated if item[2] >= 0.60 * maximum_occupied]
366
+ if dense_repeated:
367
+ selected = min(dense_repeated, key=lambda item: item[0])
368
+ else:
369
+ selected = max(
370
+ repeated,
371
+ key=lambda item: (item[1], item[2], -item[0]),
372
+ )
373
+ return selected[0], selected[1]
374
+ if stats[0][2] >= 2:
375
+ return 0, stats[0][1]
376
+ selected = max(stats, key=lambda item: (item[2], -item[0]))
377
+ return selected[0], selected[1]
378
+
379
+
380
+ def _rule_bands(
381
+ header_bottom: float,
382
+ rules: tuple[_LocalRule, ...],
383
+ width: float,
384
+ height: float,
385
+ ) -> list[tuple[float, float]]:
386
+ """用正文长横线切出有限物理行带。"""
387
+
388
+ boundaries = [header_bottom]
389
+ boundaries.extend(
390
+ rule.coordinate for rule in _internal_full_rules(rules, width, height) if rule.coordinate > header_bottom + 0.5
391
+ )
392
+ boundaries.append(height)
393
+ ordered = sorted(set(boundaries))
394
+ return [(top, bottom) for top, bottom in zip(ordered, ordered[1:]) if bottom - top > 0.5]
395
+
396
+
397
+ def _split_rows_by_anchors(
398
+ rows: list[NativeTableTextRow],
399
+ key_col: int,
400
+ occupancies_by_index: dict[int, set[int]],
401
+ *,
402
+ group_short_key_runs: bool,
403
+ ) -> list[tuple[int, ...]]:
404
+ """按关键列锚点把视觉基线拆成逻辑记录组。"""
405
+
406
+ if not rows:
407
+ return []
408
+ anchor_positions = [index for index, row in enumerate(rows) if key_col in occupancies_by_index[row.row_index]]
409
+ if not anchor_positions:
410
+ return []
411
+ if group_short_key_runs:
412
+ runs: list[list[int]] = [[anchor_positions[0]]]
413
+ for position in anchor_positions[1:]:
414
+ if position == runs[-1][-1] + 1:
415
+ runs[-1].append(position)
416
+ else:
417
+ runs.append([position])
418
+ anchors = [run[0] for run in runs]
419
+ else:
420
+ anchors = anchor_positions
421
+
422
+ groups: list[list[int]] = [[] for _ in anchors]
423
+ for position, row in enumerate(rows):
424
+ owner = 0
425
+ for index, anchor in enumerate(anchors):
426
+ if anchor <= position:
427
+ owner = index
428
+ else:
429
+ break
430
+ if position < anchors[0]:
431
+ owner = 0
432
+ groups[owner].append(row.row_index)
433
+ return [tuple(group) for group in groups if group]
434
+
435
+
436
+ def _logical_body_rows(
437
+ text: NativeTableText,
438
+ x_tracks: tuple[float, ...],
439
+ rules: tuple[_LocalRule, ...],
440
+ header_bottom: float,
441
+ width: float,
442
+ height: float,
443
+ evidence: str,
444
+ diagnostics: dict[str, Any] | None,
445
+ ) -> tuple[list[_LogicalRow], int, list[set[int]]] | None:
446
+ """结合物理行带和关键列锚点构造正文逻辑行。"""
447
+
448
+ glyph_by_id = {glyph.glyph_id: glyph for glyph in text.glyphs}
449
+ body_rows = [row for row in text.rows if (row.bbox[1] + row.bbox[3]) / 2.0 > header_bottom]
450
+ if len(body_rows) < 2:
451
+ return None
452
+ occupancies = [_row_occupancy(row, glyph_by_id, x_tracks) for row in body_rows]
453
+ occupancies_by_index = {row.row_index: occupancy for row, occupancy in zip(body_rows, occupancies, strict=True)}
454
+ body_internal_rules = [
455
+ rule
456
+ for rule in _internal_full_rules(rules, width, height)
457
+ if header_bottom + 0.5 < rule.coordinate < (body_rows[-1].bbox[1] + body_rows[-1].bbox[3]) / 2.0
458
+ ]
459
+ if evidence == "rule_band" and not body_internal_rules:
460
+ seen_first_col = False
461
+ seen_first_col_gap = False
462
+ for occupancy in occupancies:
463
+ if 0 in occupancy:
464
+ if seen_first_col and seen_first_col_gap:
465
+ if diagnostics is not None:
466
+ diagnostics["first_rejection_gate"] = "ambiguous_body_rowspan"
467
+ return None
468
+ seen_first_col = True
469
+ elif seen_first_col and occupancy != {len(x_tracks) - 2}:
470
+ seen_first_col_gap = True
471
+ key_col, key_runs = _choose_key_column(occupancies, len(x_tracks) - 1)
472
+
473
+ flags = [key_col in occupancy for occupancy in occupancies]
474
+ run_lengths: list[int] = []
475
+ gap_lengths: list[int] = []
476
+ index = 0
477
+ while index < len(flags):
478
+ if flags[index]:
479
+ end = index
480
+ while end + 1 < len(flags) and flags[end + 1]:
481
+ end += 1
482
+ run_lengths.append(end - index + 1)
483
+ index = end + 1
484
+ else:
485
+ end = index
486
+ while end + 1 < len(flags) and not flags[end + 1]:
487
+ end += 1
488
+ gap_lengths.append(end - index + 1)
489
+ index = end + 1
490
+ group_short_runs = (
491
+ evidence == "keyed_record"
492
+ and key_runs >= 3
493
+ and run_lengths
494
+ and statistics.median(run_lengths) <= 2
495
+ and gap_lengths
496
+ and statistics.median(gap_lengths) >= 2
497
+ )
498
+ if evidence == "keyed_record" and not group_short_runs:
499
+ if diagnostics is not None:
500
+ diagnostics["first_rejection_gate"] = "record_key_support"
501
+ return None
502
+
503
+ grouped_indices: list[tuple[int, ...]] = []
504
+ for top, bottom in _rule_bands(header_bottom, rules, width, height):
505
+ band_rows = [row for row in body_rows if top <= (row.bbox[1] + row.bbox[3]) / 2.0 <= bottom]
506
+ if not band_rows:
507
+ continue
508
+ grouped_indices.extend(
509
+ _split_rows_by_anchors(
510
+ band_rows,
511
+ key_col,
512
+ occupancies_by_index,
513
+ group_short_key_runs=group_short_runs,
514
+ )
515
+ )
516
+ if len(grouped_indices) < 2:
517
+ return None
518
+
519
+ row_by_index = {row.row_index: row for row in body_rows}
520
+ raw_extents = [
521
+ (
522
+ min(row_by_index[index].bbox[1] for index in indices),
523
+ max(row_by_index[index].bbox[3] for index in indices),
524
+ )
525
+ for indices in grouped_indices
526
+ ]
527
+ boundaries = [header_bottom]
528
+ for first, second in zip(raw_extents, raw_extents[1:]):
529
+ if first[1] <= second[0]:
530
+ boundary = (first[1] + second[0]) / 2.0
531
+ else:
532
+ boundary = ((first[0] + first[1]) / 2.0 + (second[0] + second[1]) / 2.0) / 2.0
533
+ boundaries.append(boundary)
534
+ boundaries.append(height)
535
+ if any(current <= previous for previous, current in zip(boundaries, boundaries[1:])):
536
+ return None
537
+ logical_rows = [
538
+ _LogicalRow(
539
+ visual_indices=indices,
540
+ top=boundaries[index],
541
+ bottom=boundaries[index + 1],
542
+ )
543
+ for index, indices in enumerate(grouped_indices)
544
+ ]
545
+ logical_occupancies = [
546
+ set().union(*(occupancies_by_index[index] for index in logical.visual_indices)) for logical in logical_rows
547
+ ]
548
+ if diagnostics is not None:
549
+ diagnostics.update(
550
+ {
551
+ "key_col": key_col,
552
+ "key_runs": key_runs,
553
+ "group_short_key_runs": group_short_runs,
554
+ "logical_body_rows": len(logical_rows),
555
+ "body_groups": [list(row.visual_indices) for row in logical_rows],
556
+ }
557
+ )
558
+ return logical_rows, key_col, logical_occupancies
559
+
560
+
561
+ def _separator_coverage(
562
+ rules: tuple[_LocalRule, ...],
563
+ y: float,
564
+ left: float,
565
+ right: float,
566
+ tolerance: float,
567
+ ) -> float:
568
+ """计算一条表头局部分隔在指定列带的覆盖率。"""
569
+
570
+ intervals = [
571
+ (rule.start, rule.end) for rule in rules if rule.orientation == "horizontal" and abs(rule.coordinate - y) <= tolerance
572
+ ]
573
+ return covered_interval_ratio(intervals, left, right)
574
+
575
+
576
+ def _header_separator(
577
+ text: NativeTableText,
578
+ rules: tuple[_LocalRule, ...],
579
+ header_bottom: float,
580
+ width: float,
581
+ ) -> float | None:
582
+ """选择表头内部唯一的完整或局部分隔线。"""
583
+
584
+ header_rows = [row for row in text.rows if (row.bbox[1] + row.bbox[3]) / 2.0 < header_bottom]
585
+ if len(header_rows) < 2:
586
+ return None
587
+ candidates = [
588
+ rule
589
+ for rule in rules
590
+ if rule.orientation == "horizontal"
591
+ and rule.end - rule.start >= 0.20 * width
592
+ and header_rows[0].bbox[3] - 0.5 < rule.coordinate < header_bottom - 0.5
593
+ ]
594
+ if not candidates:
595
+ return None
596
+ candidates.sort(
597
+ key=lambda rule: (
598
+ rule.end - rule.start,
599
+ -abs(rule.coordinate - header_bottom / 2.0),
600
+ ),
601
+ reverse=True,
602
+ )
603
+ return candidates[0].coordinate
604
+
605
+
606
+ def _header_layer_tokens(
607
+ text: NativeTableText,
608
+ top: float,
609
+ bottom: float,
610
+ ) -> list[tuple[float, float, float]]:
611
+ """收集一个表头层中 token 的水平区间和中心。"""
612
+
613
+ tokens: list[tuple[float, float, float]] = []
614
+ for row in text.rows:
615
+ center_y = (row.bbox[1] + row.bbox[3]) / 2.0
616
+ if not top <= center_y <= bottom:
617
+ continue
618
+ tokens.extend(
619
+ (
620
+ token.bbox[0],
621
+ token.bbox[2],
622
+ (token.bbox[0] + token.bbox[2]) / 2.0,
623
+ )
624
+ for token in row.tokens
625
+ )
626
+ return tokens
627
+
628
+
629
+ def _two_layer_header_specs(
630
+ text: NativeTableText,
631
+ x_tracks: tuple[float, ...],
632
+ header_bottom: float,
633
+ separator: float,
634
+ rules: tuple[_LocalRule, ...],
635
+ ) -> tuple[GridCellSpec, ...] | None:
636
+ """用表头局部分隔恢复两层表头合并格。"""
637
+
638
+ cols = len(x_tracks) - 1
639
+ tolerance = max(1.0, 0.25 * text.median_glyph_height)
640
+ coverages = [
641
+ _separator_coverage(
642
+ rules,
643
+ separator,
644
+ x_tracks[col],
645
+ x_tracks[col + 1],
646
+ tolerance,
647
+ )
648
+ for col in range(cols)
649
+ ]
650
+ if any(0.20 < coverage < 0.80 for coverage in coverages):
651
+ return None
652
+ absent_cols = {col for col, coverage in enumerate(coverages) if coverage <= 0.20}
653
+ present_cols = set(range(cols)) - absent_cols
654
+ specs: list[GridCellSpec] = [
655
+ GridCellSpec(
656
+ row=0,
657
+ col=col,
658
+ rowspan=2,
659
+ colspan=1,
660
+ bbox=(x_tracks[col], 0.0, x_tracks[col + 1], header_bottom),
661
+ )
662
+ for col in sorted(absent_cols)
663
+ ]
664
+ glyph_by_id = {glyph.glyph_id: glyph for glyph in text.glyphs}
665
+ bottom_occupied: set[int] = set()
666
+ for row in text.rows:
667
+ center_y = (row.bbox[1] + row.bbox[3]) / 2.0
668
+ if not separator < center_y < header_bottom:
669
+ continue
670
+ bottom_occupied.update(_row_occupancy(row, glyph_by_id, x_tracks))
671
+ top_tokens = _header_layer_tokens(text, 0.0, separator)
672
+ assignments: dict[int, list[int]] = {index: [] for index in range(len(top_tokens))}
673
+ for col in sorted(bottom_occupied.intersection(present_cols)):
674
+ if not top_tokens:
675
+ return None
676
+ col_center = (x_tracks[col] + x_tracks[col + 1]) / 2.0
677
+ owner = min(
678
+ range(len(top_tokens)),
679
+ key=lambda index: abs(col_center - top_tokens[index][2]),
680
+ )
681
+ assignments[owner].append(col)
682
+
683
+ covered: set[int] = set()
684
+ for index, (_left, _right, center) in enumerate(top_tokens):
685
+ group = assignments[index]
686
+ if not group:
687
+ continue
688
+ if group != list(range(group[0], group[-1] + 1)):
689
+ return None
690
+ if not x_tracks[group[0]] <= center <= x_tracks[group[-1] + 1]:
691
+ return None
692
+ specs.append(
693
+ GridCellSpec(
694
+ row=0,
695
+ col=group[0],
696
+ rowspan=1,
697
+ colspan=group[-1] - group[0] + 1,
698
+ bbox=(
699
+ x_tracks[group[0]],
700
+ 0.0,
701
+ x_tracks[group[-1] + 1],
702
+ separator,
703
+ ),
704
+ )
705
+ )
706
+ covered.update(group)
707
+ for col in sorted(present_cols - covered):
708
+ specs.append(
709
+ GridCellSpec(
710
+ row=0,
711
+ col=col,
712
+ rowspan=1,
713
+ colspan=1,
714
+ bbox=(x_tracks[col], 0.0, x_tracks[col + 1], separator),
715
+ )
716
+ )
717
+ for col in sorted(present_cols):
718
+ specs.append(
719
+ GridCellSpec(
720
+ row=1,
721
+ col=col,
722
+ rowspan=1,
723
+ colspan=1,
724
+ bbox=(x_tracks[col], separator, x_tracks[col + 1], header_bottom),
725
+ )
726
+ )
727
+ return tuple(specs)
728
+
729
+
730
+ def _logical_cell_has_glyph(
731
+ text: NativeTableText,
732
+ logical_row: _LogicalRow,
733
+ col: int,
734
+ x_tracks: tuple[float, ...],
735
+ ) -> bool:
736
+ """判断一个逻辑正文格是否含有字符中心。"""
737
+
738
+ visual_indices = set(logical_row.visual_indices)
739
+ for glyph in text.glyphs:
740
+ if glyph.visual_row not in visual_indices:
741
+ continue
742
+ center_x = (glyph.bbox[0] + glyph.bbox[2]) / 2.0
743
+ if x_tracks[col] <= center_x <= x_tracks[col + 1]:
744
+ return True
745
+ return False
746
+
747
+
748
+ def _body_specs(
749
+ text: NativeTableText,
750
+ x_tracks: tuple[float, ...],
751
+ logical_rows: list[_LogicalRow],
752
+ row_offset: int,
753
+ evidence: str,
754
+ key_col: int,
755
+ ) -> tuple[GridCellSpec, ...]:
756
+ """构造正文网格,并仅在填充记录表中推断首列 rowspan。"""
757
+
758
+ cols = len(x_tracks) - 1
759
+ specs: list[GridCellSpec] = []
760
+ span_lengths: dict[int, int] = {}
761
+ if evidence == "filled_record" and key_col > 0:
762
+ occupied_rows = [
763
+ index for index, logical in enumerate(logical_rows) if _logical_cell_has_glyph(text, logical, 0, x_tracks)
764
+ ]
765
+ for position, start in enumerate(occupied_rows):
766
+ end = occupied_rows[position + 1] if position + 1 < len(occupied_rows) else len(logical_rows)
767
+ span_lengths[start] = max(1, end - start)
768
+
769
+ covered_first_col: set[int] = set()
770
+ for body_row, logical in enumerate(logical_rows):
771
+ if body_row in span_lengths:
772
+ rowspan = span_lengths[body_row]
773
+ specs.append(
774
+ GridCellSpec(
775
+ row=row_offset + body_row,
776
+ col=0,
777
+ rowspan=rowspan,
778
+ colspan=1,
779
+ bbox=(
780
+ x_tracks[0],
781
+ logical.top,
782
+ x_tracks[1],
783
+ logical_rows[body_row + rowspan - 1].bottom,
784
+ ),
785
+ )
786
+ )
787
+ covered_first_col.update(range(body_row, body_row + rowspan))
788
+ elif body_row not in covered_first_col:
789
+ specs.append(
790
+ GridCellSpec(
791
+ row=row_offset + body_row,
792
+ col=0,
793
+ rowspan=1,
794
+ colspan=1,
795
+ bbox=(x_tracks[0], logical.top, x_tracks[1], logical.bottom),
796
+ )
797
+ )
798
+ for col in range(1, cols):
799
+ specs.append(
800
+ GridCellSpec(
801
+ row=row_offset + body_row,
802
+ col=col,
803
+ rowspan=1,
804
+ colspan=1,
805
+ bbox=(
806
+ x_tracks[col],
807
+ logical.top,
808
+ x_tracks[col + 1],
809
+ logical.bottom,
810
+ ),
811
+ )
812
+ )
813
+ return tuple(specs)
814
+
815
+
816
+ def _stable_gutters(
817
+ text: NativeTableText,
818
+ x_tracks: tuple[float, ...],
819
+ physical_boundaries: frozenset[int],
820
+ header_bottom: float,
821
+ diagnostics: dict[str, Any] | None,
822
+ ) -> bool:
823
+ """校验每条文本列边界都由稳定空白走廊或物理边缘支持。"""
824
+
825
+ edge_tolerance = max(0.15, 0.03 * text.median_glyph_width)
826
+ supports: list[int] = []
827
+ glyphs_by_row: dict[int, list[NativeTableGlyph]] = {}
828
+ for glyph in text.glyphs:
829
+ glyphs_by_row.setdefault(glyph.visual_row, []).append(glyph)
830
+ body_glyphs = [
831
+ glyph
832
+ for glyph in text.glyphs
833
+ if (text.rows[glyph.visual_row].bbox[1] + text.rows[glyph.visual_row].bbox[3]) / 2.0 > header_bottom
834
+ ]
835
+ for boundary_index, boundary in enumerate(x_tracks[1:-1], start=1):
836
+ if any(glyph.bbox[0] + edge_tolerance < boundary < glyph.bbox[2] - edge_tolerance for glyph in body_glyphs):
837
+ if diagnostics is not None:
838
+ diagnostics.update(
839
+ {
840
+ "failed_gutter_boundary": boundary_index,
841
+ "failed_gutter_reason": "glyph_crossing",
842
+ }
843
+ )
844
+ return False
845
+ if boundary_index in physical_boundaries:
846
+ supports.append(len(text.rows))
847
+ continue
848
+ comparable = 0
849
+ stable = 0
850
+ for row in text.rows:
851
+ if (row.bbox[1] + row.bbox[3]) / 2.0 <= header_bottom:
852
+ continue
853
+ row_glyphs = glyphs_by_row.get(row.row_index, [])
854
+ left_glyphs = [glyph for glyph in row_glyphs if glyph.bbox[2] <= boundary]
855
+ right_glyphs = [glyph for glyph in row_glyphs if glyph.bbox[0] >= boundary]
856
+ if not left_glyphs or not right_glyphs:
857
+ continue
858
+ comparable += 1
859
+ gap = min(glyph.bbox[0] for glyph in right_glyphs) - max(glyph.bbox[2] for glyph in left_glyphs)
860
+ if gap >= max(0.25, 0.10 * text.median_glyph_width):
861
+ stable += 1
862
+ if comparable < 3 or stable / comparable < 0.80:
863
+ if diagnostics is not None:
864
+ diagnostics.update(
865
+ {
866
+ "failed_gutter_boundary": boundary_index,
867
+ "failed_gutter_reason": "support",
868
+ "failed_gutter_comparable": comparable,
869
+ "failed_gutter_stable": stable,
870
+ }
871
+ )
872
+ return False
873
+ supports.append(stable)
874
+ if diagnostics is not None:
875
+ diagnostics["gutter_supports"] = supports
876
+ return True
877
+
878
+
879
+ def _has_overlapping_formula_rows(
880
+ text: NativeTableText,
881
+ header_bottom: float,
882
+ ) -> bool:
883
+ """识别高公式字符框跨越相邻逻辑行的危险表格。"""
884
+
885
+ body_rows = [row for row in text.rows if (row.bbox[1] + row.bbox[3]) / 2.0 > header_bottom]
886
+ if any(row.bbox[3] - row.bbox[1] > 3.0 * text.median_glyph_height for row in body_rows):
887
+ return True
888
+ return False
889
+
890
+
891
+ def _has_ambiguous_body_descriptor(
892
+ logical_occupancies: list[set[int]],
893
+ evidence: str,
894
+ ) -> bool:
895
+ """识别无线正文中首列空缺后再次出现而无法唯一确定 rowspan 的情况。"""
896
+
897
+ if evidence == "filled_record":
898
+ return False
899
+ seen_nonempty = False
900
+ seen_gap = False
901
+ for occupancy in logical_occupancies:
902
+ if 0 in occupancy:
903
+ if seen_nonempty and seen_gap:
904
+ return True
905
+ seen_nonempty = True
906
+ elif seen_nonempty:
907
+ seen_gap = True
908
+ return False
909
+
910
+
911
+ def _build_candidate(
912
+ table_input: NativeTableInput,
913
+ text: NativeTableText,
914
+ diagnostics: dict[str, Any] | None,
915
+ ) -> NativeTableCandidate | None:
916
+ """构造一个末级多行少线候选并执行高置信硬门。"""
917
+
918
+ table_bbox = normalize_bbox(table_input.table_bbox)
919
+ if table_bbox is None:
920
+ return None
921
+ width, height = table_local_size(
922
+ table_bbox,
923
+ normalize_angle(table_input.angle),
924
+ )
925
+ rules = _local_rules(table_input, width, height)
926
+ rectangles = _local_rectangles(table_input, width, height)
927
+ hypothesis = _build_column_hypothesis(
928
+ text,
929
+ width,
930
+ rules,
931
+ rectangles,
932
+ diagnostics,
933
+ )
934
+ if hypothesis is None:
935
+ return None
936
+ header_bottom = _infer_header_boundary(
937
+ text,
938
+ hypothesis.x_tracks,
939
+ rules,
940
+ width,
941
+ height,
942
+ hypothesis.evidence,
943
+ )
944
+ if header_bottom is None:
945
+ if diagnostics is not None:
946
+ diagnostics["first_rejection_gate"] = "header_boundary"
947
+ return None
948
+ if _has_overlapping_formula_rows(text, header_bottom):
949
+ if diagnostics is not None:
950
+ diagnostics["first_rejection_gate"] = "overlapping_formula_rows"
951
+ return None
952
+ body = _logical_body_rows(
953
+ text,
954
+ hypothesis.x_tracks,
955
+ rules,
956
+ header_bottom,
957
+ width,
958
+ height,
959
+ hypothesis.evidence,
960
+ diagnostics,
961
+ )
962
+ if body is None:
963
+ if diagnostics is not None and diagnostics.get("first_rejection_gate") is None:
964
+ diagnostics["first_rejection_gate"] = "logical_rows"
965
+ return None
966
+ logical_rows, key_col, logical_occupancies = body
967
+ if _has_ambiguous_body_descriptor(
968
+ logical_occupancies,
969
+ hypothesis.evidence,
970
+ ):
971
+ if diagnostics is not None:
972
+ diagnostics["first_rejection_gate"] = "ambiguous_body_rowspan"
973
+ return None
974
+ if not _stable_gutters(
975
+ text,
976
+ hypothesis.x_tracks,
977
+ hypothesis.physical_boundaries,
978
+ header_bottom,
979
+ diagnostics,
980
+ ):
981
+ if diagnostics is not None:
982
+ diagnostics["first_rejection_gate"] = "gutter_support"
983
+ return None
984
+
985
+ separator = _header_separator(text, rules, header_bottom, width)
986
+ if separator is None:
987
+ header_rows = 1
988
+ header_specs = tuple(
989
+ GridCellSpec(
990
+ row=0,
991
+ col=col,
992
+ rowspan=1,
993
+ colspan=1,
994
+ bbox=(
995
+ hypothesis.x_tracks[col],
996
+ 0.0,
997
+ hypothesis.x_tracks[col + 1],
998
+ header_bottom,
999
+ ),
1000
+ )
1001
+ for col in range(len(hypothesis.x_tracks) - 1)
1002
+ )
1003
+ else:
1004
+ header_rows = 2
1005
+ header_specs = _two_layer_header_specs(
1006
+ text,
1007
+ hypothesis.x_tracks,
1008
+ header_bottom,
1009
+ separator,
1010
+ rules,
1011
+ )
1012
+ if header_specs is None:
1013
+ if diagnostics is not None:
1014
+ diagnostics["first_rejection_gate"] = "header_topology"
1015
+ return None
1016
+ body_specs = _body_specs(
1017
+ text,
1018
+ hypothesis.x_tracks,
1019
+ logical_rows,
1020
+ header_rows,
1021
+ hypothesis.evidence,
1022
+ key_col,
1023
+ )
1024
+ candidate_diagnostics: dict[str, object] = {}
1025
+ candidate = build_candidate(
1026
+ source="sparse_multiline",
1027
+ rows=header_rows + len(logical_rows),
1028
+ cols=len(hypothesis.x_tracks) - 1,
1029
+ specs=(*header_specs, *body_specs),
1030
+ text=text,
1031
+ structure_support=1.0,
1032
+ row_stability=1.0,
1033
+ column_stability=1.0,
1034
+ issues=(
1035
+ f"evidence={hypothesis.evidence}",
1036
+ f"header_rows={header_rows}",
1037
+ f"logical_body_rows={len(logical_rows)}",
1038
+ f"key_col={key_col}",
1039
+ f"filled_band_count={hypothesis.filled_band_count}",
1040
+ ),
1041
+ use_grid_index=True,
1042
+ diagnostics=candidate_diagnostics,
1043
+ )
1044
+ if candidate is None:
1045
+ if diagnostics is not None:
1046
+ diagnostics["first_rejection_gate"] = candidate_diagnostics.get(
1047
+ "candidate_rejection_gate",
1048
+ "candidate_hard_gate",
1049
+ )
1050
+ return None
1051
+ if diagnostics is not None:
1052
+ diagnostics["candidate_diagnostics"] = dict(candidate_diagnostics)
1053
+ diagnostics["candidate_components"] = {
1054
+ "text_capture": candidate.text_capture,
1055
+ "order_consistency": candidate.order_consistency,
1056
+ "score": candidate.score,
1057
+ "ambiguous_glyph_ratio": candidate_diagnostics.get(
1058
+ "ambiguous_glyph_ratio",
1059
+ 1.0,
1060
+ ),
1061
+ }
1062
+ ambiguous_ratio = float(candidate_diagnostics.get("ambiguous_glyph_ratio", 1.0))
1063
+ if (
1064
+ candidate.text_capture < 1.0
1065
+ or candidate.order_consistency < 1.0
1066
+ or ambiguous_ratio > 0.0
1067
+ or candidate.score < MIN_MULTILINE_RELIABILITY
1068
+ ):
1069
+ if diagnostics is not None:
1070
+ diagnostics["first_rejection_gate"] = "verified_integrity"
1071
+ return None
1072
+ if hypothesis.evidence == "keyed_record" and len(logical_rows) < 3:
1073
+ if diagnostics is not None:
1074
+ diagnostics["first_rejection_gate"] = "record_count"
1075
+ return None
1076
+ if diagnostics is not None:
1077
+ diagnostics.update(
1078
+ {
1079
+ "first_rejection_gate": None,
1080
+ "grid": {"rows": candidate.rows, "cols": candidate.cols},
1081
+ "header_bottom": header_bottom,
1082
+ "header_rows": header_rows,
1083
+ "score": candidate.score,
1084
+ "token_split_count": candidate_diagnostics.get(
1085
+ "token_split_count",
1086
+ 0,
1087
+ ),
1088
+ }
1089
+ )
1090
+ return candidate
1091
+
1092
+
1093
+ def build_sparse_multiline_candidates(
1094
+ table_input: NativeTableInput,
1095
+ text: NativeTableText,
1096
+ diagnostics: list[dict[str, Any]] | None = None,
1097
+ ) -> list[NativeTableCandidate]:
1098
+ """生成仅在既有候选全部失败后运行的多行少线候选。"""
1099
+
1100
+ record: dict[str, Any] | None = {"source": "sparse_multiline"} if diagnostics is not None else None
1101
+ candidate = _build_candidate(table_input, text, record)
1102
+ if diagnostics is not None and record is not None:
1103
+ diagnostics.append(record)
1104
+ return [candidate] if candidate is not None else []
1105
+
1106
+
1107
+ def diagnose_sparse_multiline_candidate_builds(
1108
+ table_input: NativeTableInput,
1109
+ text: NativeTableText,
1110
+ ) -> tuple[dict[str, Any], ...]:
1111
+ """重放多行少线候选构造并返回私有诊断。"""
1112
+
1113
+ diagnostics: list[dict[str, Any]] = []
1114
+ build_sparse_multiline_candidates(
1115
+ table_input,
1116
+ text,
1117
+ diagnostics=diagnostics,
1118
+ )
1119
+ return tuple(diagnostics)
1120
+
1121
+
1122
+ __all__ = ["build_sparse_multiline_candidates"]