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,1129 @@
1
+ """PDF 规则线与文本行候选;保留原有认领顺序与判定规则。"""
2
+
3
+ from __future__ import annotations
4
+ import statistics
5
+ from typing import Any
6
+ from ....schema import BBox
7
+ from ....document.pdf.document import PDFPathInfo
8
+ from .models import _Fragment, _LineItem, _LocalAxisLine, _PageSource, _TableCandidate, _VisualRow
9
+ from .geometry import (
10
+ _bbox_area,
11
+ _bbox_axis_overlap_ratio,
12
+ _bbox_center_x,
13
+ _bbox_center_y,
14
+ _bbox_overlap_in_smaller,
15
+ _bbox_union,
16
+ _bbox_union_many,
17
+ _point_in_bbox,
18
+ _rotate_bbox_from_upright,
19
+ _rotate_bbox_to_upright,
20
+ _transform_axis_lines,
21
+ )
22
+
23
+ from .table_annotations import (
24
+ _build_table_annotation,
25
+ _collect_caption_rows,
26
+ _collect_footnote_rows,
27
+ _find_table_caption,
28
+ _merge_table_candidate_annotations,
29
+ )
30
+ from .table_rows import _clip_visual_row_to_corridor
31
+
32
+
33
+ def _build_fragments(
34
+ lines: list[_LineItem],
35
+ page_size: tuple[float, float],
36
+ ) -> list[_Fragment]:
37
+ """将精修后的原生 run 转换成表格单元候选。"""
38
+
39
+ fragments: list[_Fragment] = []
40
+ for line in lines:
41
+ local_bbox = _rotate_bbox_to_upright(line.bbox, page_size, line.angle)
42
+ fragments.append(
43
+ _Fragment(
44
+ text=line.text,
45
+ bbox=line.bbox,
46
+ local_bbox=local_bbox,
47
+ line_index=line.source_index,
48
+ # 复用原生粗行身份,避免同一字符行内不同 cell
49
+ # 因轻微基线差异被误拆成多行。
50
+ visual_row_id=line.visual_row_id,
51
+ )
52
+ )
53
+ return fragments
54
+
55
+
56
+ def _cluster_fragment_rows(
57
+ fragments: list[_Fragment],
58
+ median_height: float,
59
+ ) -> list[_VisualRow]:
60
+ """优先复用原生视觉行身份,其余片段按中心线容差聚成表格行。"""
61
+
62
+ tolerance = max(2.0, median_height * 0.5)
63
+ native_groups: dict[int, list[_Fragment]] = {}
64
+ geometric_fragments: list[_Fragment] = []
65
+ for fragment in fragments:
66
+ if fragment.visual_row_id is None:
67
+ geometric_fragments.append(fragment)
68
+ else:
69
+ native_groups.setdefault(fragment.visual_row_id, []).append(fragment)
70
+
71
+ # 先锁定同一原生粗行拆出的 run,再允许不同粗行按基线几何合并;
72
+ # 旋转表格常把同一数据行的各 cell 分成多个 pdftext 粗行,不能只依赖 row id。
73
+ seed_groups = [*native_groups.values(), *[[fragment] for fragment in geometric_fragments]]
74
+ seed_groups.sort(
75
+ key=lambda group: (
76
+ statistics.fmean(_bbox_center_y(item.local_bbox) for item in group),
77
+ min(item.local_bbox[0] for item in group),
78
+ )
79
+ )
80
+ grouped: list[list[_Fragment]] = []
81
+ for seed_group in seed_groups:
82
+ center_y = statistics.fmean(_bbox_center_y(item.local_bbox) for item in seed_group)
83
+ target_group: list[_Fragment] | None = None
84
+ for group in grouped:
85
+ group_center = statistics.fmean(_bbox_center_y(item.local_bbox) for item in group)
86
+ if abs(center_y - group_center) <= tolerance:
87
+ target_group = group
88
+ break
89
+ if target_group is None:
90
+ grouped.append(list(seed_group))
91
+ else:
92
+ target_group.extend(seed_group)
93
+
94
+ rows: list[_VisualRow] = []
95
+ for group in grouped:
96
+ group.sort(key=lambda item: item.local_bbox[0])
97
+ bbox = _bbox_union_many([item.local_bbox for item in group])
98
+ visual_row_ids = {item.visual_row_id for item in group if item.visual_row_id is not None}
99
+ rows.append(
100
+ _VisualRow(
101
+ fragments=group,
102
+ center_y=sum(_bbox_center_y(item.local_bbox) for item in group) / len(group),
103
+ bbox=bbox,
104
+ visual_row_id=next(iter(visual_row_ids)) if len(visual_row_ids) == 1 else None,
105
+ )
106
+ )
107
+ rows.sort(key=lambda row: row.center_y)
108
+ return rows
109
+
110
+
111
+ def _build_rule_table_candidates(
112
+ rows: list[_VisualRow],
113
+ lines: list[_LineItem],
114
+ page_size: tuple[float, float],
115
+ angle: int,
116
+ median_height: float,
117
+ axis_lines: list[_LocalAxisLine],
118
+ *,
119
+ path_infos: list[PDFPathInfo] | None = None,
120
+ excluded_bboxes: list[BBox] | None = None,
121
+ ) -> list[_TableCandidate]:
122
+ """枚举同跨度横线边界区间,再以连续多列文本分布确认表格。"""
123
+
124
+ candidates: list[_TableCandidate] = []
125
+ path_infos = path_infos or []
126
+ excluded_bboxes = excluded_bboxes or []
127
+ for rule_group in _group_long_horizontal_rules(axis_lines, median_height):
128
+ for first_index, top_rule in enumerate(rule_group[:-1]):
129
+ for bottom_index in range(first_index + 1, len(rule_group)):
130
+ bottom_rule = rule_group[bottom_index]
131
+ interval_rules = rule_group[first_index : bottom_index + 1]
132
+ boundary_rules = [top_rule, bottom_rule]
133
+ rule_bbox = _bbox_union_many([line.bbox for line in boundary_rules])
134
+ core_rows = _rows_inside_rule_interval(
135
+ rows,
136
+ rule_bbox,
137
+ excluded_bboxes,
138
+ )
139
+ caption_line = _find_table_caption(
140
+ lines,
141
+ rule_bbox,
142
+ page_size,
143
+ angle,
144
+ median_height,
145
+ )
146
+ caption_anchored_compact_grid = (
147
+ caption_line is not None
148
+ and len(interval_rules) >= 3
149
+ and rule_bbox[3] - rule_bbox[1] <= 0.15 * (page_size[0] if angle in {90, 270} else page_size[1])
150
+ )
151
+ if (
152
+ not _every_rule_interval_has_multi_cell_row(
153
+ core_rows,
154
+ interval_rules,
155
+ median_height,
156
+ )
157
+ and not caption_anchored_compact_grid
158
+ ):
159
+ continue
160
+ if (
161
+ not _rule_intervals_are_column_compatible(
162
+ core_rows,
163
+ interval_rules,
164
+ median_height,
165
+ )
166
+ and not caption_anchored_compact_grid
167
+ ):
168
+ continue
169
+
170
+ fill_band_count = _count_repeated_fill_bands(
171
+ path_infos,
172
+ rule_bbox,
173
+ page_size,
174
+ angle,
175
+ median_height,
176
+ )
177
+ aligned_vertical_count = _count_aligned_vertical_rules(
178
+ axis_lines,
179
+ rule_bbox,
180
+ median_height,
181
+ )
182
+
183
+ row_segments = _continuous_table_row_segments(core_rows, median_height)
184
+ accepted: tuple[list[_VisualRow], list[_VisualRow], int, float] | None = None
185
+ for row_segment in row_segments:
186
+ dense_rows = [row for row in row_segment if len(row.fragments) >= 2]
187
+ compact_grid_columns = (
188
+ _compact_fully_ruled_grid_column_count(
189
+ row_segment,
190
+ dense_rows,
191
+ interval_rules,
192
+ axis_lines,
193
+ rule_bbox,
194
+ median_height,
195
+ )
196
+ if len(dense_rows) == 2
197
+ else 0
198
+ )
199
+ stable_columns, column_coverage = _count_stable_columns(
200
+ dense_rows,
201
+ median_height,
202
+ )
203
+ if compact_grid_columns > 0:
204
+ # 两行样本容易把左右/中心锚点误算成不同稳定列,使用物理网格列数。
205
+ stable_columns = compact_grid_columns
206
+ caption_supported_compact_rows = (
207
+ caption_anchored_compact_grid
208
+ and len(dense_rows) >= 2
209
+ and stable_columns >= 3
210
+ and column_coverage >= 0.5
211
+ )
212
+ if len(dense_rows) < 3 and compact_grid_columns == 0 and not caption_supported_compact_rows:
213
+ continue
214
+ # 真表格的多单元行会在整个数据带内反复出现;少数图题、图例和
215
+ # 坐标刻度偶然形成的多列行不能支撑一大片正文区域。
216
+ if len(dense_rows) / len(row_segment) < 0.2:
217
+ continue
218
+ if stable_columns < 2 or column_coverage < 0.5:
219
+ continue
220
+ if _looks_like_page_column_prose(
221
+ row_segment,
222
+ dense_rows,
223
+ stable_columns,
224
+ fill_band_count,
225
+ aligned_vertical_count,
226
+ rule_bbox,
227
+ ):
228
+ continue
229
+ if not _table_segment_reaches_boundaries(
230
+ row_segment,
231
+ rule_bbox,
232
+ median_height,
233
+ ):
234
+ continue
235
+ if not _table_rows_align_with_rule_span(
236
+ row_segment,
237
+ rule_bbox,
238
+ median_height,
239
+ ):
240
+ continue
241
+ result = (
242
+ row_segment,
243
+ dense_rows,
244
+ stable_columns,
245
+ column_coverage,
246
+ )
247
+ if accepted is None or (
248
+ len(row_segment),
249
+ len(dense_rows),
250
+ stable_columns,
251
+ column_coverage,
252
+ ) > (
253
+ len(accepted[0]),
254
+ len(accepted[1]),
255
+ accepted[2],
256
+ accepted[3],
257
+ ):
258
+ accepted = result
259
+ if accepted is None:
260
+ continue
261
+
262
+ accepted_rows, dense_rows, stable_columns, _coverage = accepted
263
+ candidate = _expand_rule_table_candidate(
264
+ boundary_rules,
265
+ accepted_rows,
266
+ rows,
267
+ lines,
268
+ page_size,
269
+ angle,
270
+ median_height,
271
+ caption_line,
272
+ )
273
+ candidate.score = float(2 + len(dense_rows) + stable_columns + min(fill_band_count, 8))
274
+ candidates.append(candidate)
275
+ return _expand_candidates_to_connected_rule_grids(
276
+ candidates,
277
+ rows,
278
+ page_size,
279
+ angle,
280
+ median_height,
281
+ axis_lines,
282
+ excluded_bboxes,
283
+ )
284
+
285
+
286
+ def _expand_candidates_to_connected_rule_grids(
287
+ candidates: list[_TableCandidate],
288
+ rows: list[_VisualRow],
289
+ page_size: tuple[float, float],
290
+ angle: int,
291
+ median_height: float,
292
+ axis_lines: list[_LocalAxisLine],
293
+ excluded_bboxes: list[BBox],
294
+ ) -> list[_TableCandidate]:
295
+ """把已确认候选沿连续横边界和贯穿竖轨扩展到完整物理网格。"""
296
+
297
+ grid_bboxes = [
298
+ grid_bbox
299
+ for grid_bbox in _connected_rule_grid_bboxes(axis_lines, median_height)
300
+ if not any(_bbox_overlap_in_smaller(grid_bbox, excluded_bbox) >= 0.5 for excluded_bbox in excluded_bboxes)
301
+ ]
302
+ if not grid_bboxes:
303
+ return candidates
304
+
305
+ tolerance = max(2.0, median_height)
306
+ for candidate in candidates:
307
+ if candidate.core_bbox is None:
308
+ continue
309
+ core_local_bbox = _rotate_bbox_to_upright(
310
+ candidate.core_bbox,
311
+ page_size,
312
+ angle,
313
+ )
314
+ matches = [
315
+ grid_bbox
316
+ for grid_bbox in grid_bboxes
317
+ if _bbox_axis_overlap_ratio(
318
+ core_local_bbox,
319
+ grid_bbox,
320
+ axis="x",
321
+ )
322
+ >= 0.9
323
+ and core_local_bbox[3] >= grid_bbox[1] - tolerance
324
+ and core_local_bbox[1] <= grid_bbox[3] + tolerance
325
+ ]
326
+ if not matches:
327
+ continue
328
+ grid_bbox = max(
329
+ matches,
330
+ key=lambda bbox: (
331
+ min(core_local_bbox[3], bbox[3]) - max(core_local_bbox[1], bbox[1]),
332
+ _bbox_area(bbox),
333
+ ),
334
+ )
335
+ expanded_core_bbox = _bbox_union(core_local_bbox, grid_bbox)
336
+ candidate.local_bbox = _bbox_union(candidate.local_bbox, grid_bbox)
337
+ candidate.core_bbox = _rotate_bbox_from_upright(
338
+ expanded_core_bbox,
339
+ page_size,
340
+ angle,
341
+ )
342
+ candidate.bbox = _rotate_bbox_from_upright(
343
+ candidate.local_bbox,
344
+ page_size,
345
+ angle,
346
+ )
347
+ for row in rows:
348
+ if not expanded_core_bbox[1] <= row.center_y <= expanded_core_bbox[3]:
349
+ continue
350
+ candidate.line_indices.update(
351
+ fragment.line_index
352
+ for fragment in row.fragments
353
+ if _point_in_bbox(
354
+ (
355
+ _bbox_center_x(fragment.local_bbox),
356
+ _bbox_center_y(fragment.local_bbox),
357
+ ),
358
+ expanded_core_bbox,
359
+ )
360
+ )
361
+ for annotation in candidate.annotations:
362
+ candidate.line_indices.difference_update(annotation.line_indices)
363
+ return candidates
364
+
365
+
366
+ def _build_closed_rule_grid_candidates(
367
+ rows: list[_VisualRow],
368
+ lines: list[_LineItem],
369
+ page_size: tuple[float, float],
370
+ angle: int,
371
+ median_height: float,
372
+ axis_lines: list[_LocalAxisLine],
373
+ excluded_bboxes: list[BBox],
374
+ ) -> list[_TableCandidate]:
375
+ """用闭合物理网格接纳含空行或仅有表头文本的稀疏表格。"""
376
+
377
+ candidates: list[_TableCandidate] = []
378
+ for component in _connected_rule_grid_components(axis_lines, median_height):
379
+ grid_bbox = _bbox_union_many([rule.bbox for rule in component])
380
+ if any(_bbox_overlap_in_smaller(grid_bbox, excluded_bbox) >= 0.5 for excluded_bbox in excluded_bboxes):
381
+ continue
382
+ core_rows = _rows_inside_rule_interval(
383
+ rows,
384
+ grid_bbox,
385
+ excluded_bboxes,
386
+ )
387
+ if not core_rows:
388
+ continue
389
+
390
+ vertical_positions = _closed_grid_vertical_track_positions(
391
+ component,
392
+ axis_lines,
393
+ median_height,
394
+ )
395
+ if len(vertical_positions) < 2:
396
+ continue
397
+ edge_tolerance = max(2.0, 0.25 * median_height)
398
+ if (
399
+ abs(vertical_positions[0] - grid_bbox[0]) > edge_tolerance
400
+ or abs(vertical_positions[-1] - grid_bbox[2]) > edge_tolerance
401
+ ):
402
+ continue
403
+
404
+ if len(component) == 2:
405
+ if len(vertical_positions) < 3:
406
+ continue
407
+ occupied_columns = _count_occupied_closed_grid_columns(
408
+ core_rows,
409
+ vertical_positions,
410
+ )
411
+ if occupied_columns < 2:
412
+ continue
413
+
414
+ caption_line = _find_table_caption(
415
+ lines,
416
+ grid_bbox,
417
+ page_size,
418
+ angle,
419
+ median_height,
420
+ )
421
+ candidate = _expand_rule_table_candidate(
422
+ [component[0], component[-1]],
423
+ core_rows,
424
+ rows,
425
+ lines,
426
+ page_size,
427
+ angle,
428
+ median_height,
429
+ caption_line,
430
+ )
431
+ candidate.score = float(100 + len(component) + len(vertical_positions))
432
+ candidates.append(candidate)
433
+ return candidates
434
+
435
+
436
+ def _closed_grid_vertical_track_positions(
437
+ horizontal_rules: list[_LocalAxisLine],
438
+ axis_lines: list[_LocalAxisLine],
439
+ median_height: float,
440
+ ) -> list[float]:
441
+ """收集覆盖首末横边界中心跨度至少九成的竖轨并合并重复路径。"""
442
+
443
+ top = _bbox_center_y(horizontal_rules[0].bbox)
444
+ bottom = _bbox_center_y(horizontal_rules[-1].bbox)
445
+ grid_height = max(0.1, bottom - top)
446
+ left = min(rule.bbox[0] for rule in horizontal_rules)
447
+ right = max(rule.bbox[2] for rule in horizontal_rules)
448
+ edge_tolerance = max(2.0, 0.25 * median_height)
449
+ raw_positions = []
450
+ for line in axis_lines:
451
+ if line.orientation != "vertical":
452
+ continue
453
+ overlap = max(
454
+ 0.0,
455
+ min(line.bbox[3], bottom) - max(line.bbox[1], top),
456
+ )
457
+ position = _bbox_center_x(line.bbox)
458
+ if overlap / grid_height >= 0.9 and left - edge_tolerance <= position <= right + edge_tolerance:
459
+ raw_positions.append(position)
460
+
461
+ position_tolerance = max(1.0, 0.1 * median_height)
462
+ position_groups: list[list[float]] = []
463
+ for position in sorted(raw_positions):
464
+ if position_groups and abs(position - statistics.mean(position_groups[-1])) <= position_tolerance:
465
+ position_groups[-1].append(position)
466
+ else:
467
+ position_groups.append([position])
468
+ return [statistics.mean(group) for group in position_groups]
469
+
470
+
471
+ def _count_occupied_closed_grid_columns(
472
+ rows: list[_VisualRow],
473
+ vertical_positions: list[float],
474
+ ) -> int:
475
+ """按文本片段中心统计闭合网格中实际有文字的物理列数。"""
476
+
477
+ occupied_columns: set[int] = set()
478
+ for row in rows:
479
+ for fragment in row.fragments:
480
+ center_x = _bbox_center_x(fragment.local_bbox)
481
+ matching_columns = [
482
+ index
483
+ for index, (left, right) in enumerate(zip(vertical_positions, vertical_positions[1:]))
484
+ if left < center_x < right
485
+ ]
486
+ if len(matching_columns) == 1:
487
+ occupied_columns.add(matching_columns[0])
488
+ return len(occupied_columns)
489
+
490
+
491
+ def _connected_rule_grid_bboxes(
492
+ axis_lines: list[_LocalAxisLine],
493
+ median_height: float,
494
+ ) -> list[BBox]:
495
+ """把端点一致且由外轨或至少两条列轨贯穿的相邻横线组成网格框。"""
496
+
497
+ return [
498
+ _bbox_union_many([rule.bbox for rule in component])
499
+ for component in _connected_rule_grid_components(
500
+ axis_lines,
501
+ median_height,
502
+ )
503
+ ]
504
+
505
+
506
+ def _connected_rule_grid_components(
507
+ axis_lines: list[_LocalAxisLine],
508
+ median_height: float,
509
+ ) -> list[list[_LocalAxisLine]]:
510
+ """保留连续网格的横边界成员,供精确外轨和横边界数量校验。"""
511
+
512
+ output: list[list[_LocalAxisLine]] = []
513
+ for rule_group in _group_long_horizontal_rules(axis_lines, median_height):
514
+ components: list[list[_LocalAxisLine]] = []
515
+ for rule in rule_group:
516
+ if not components or not _rule_bands_share_grid_tracks(
517
+ components[-1][-1],
518
+ rule,
519
+ axis_lines,
520
+ median_height,
521
+ ):
522
+ components.append([rule])
523
+ else:
524
+ components[-1].append(rule)
525
+ output.extend(component for component in components if len(component) >= 2)
526
+ return output
527
+
528
+
529
+ def _connected_horizontal_rule_bboxes(
530
+ source: _PageSource,
531
+ ) -> set[BBox]:
532
+ """返回参与常规横排闭合网格的原始水平线框,供上游避免删除真实表格边界。"""
533
+
534
+ angle_lines = [line for line in source.lines if line.angle == 0]
535
+ fragments = _build_fragments(angle_lines, source.page_size)
536
+ if not fragments:
537
+ return set()
538
+ local_axis_lines = _transform_axis_lines(
539
+ source.drawing_lines,
540
+ source.page_size,
541
+ 0,
542
+ )
543
+ return {
544
+ rule.original_bbox
545
+ for component in _connected_rule_grid_components(
546
+ local_axis_lines,
547
+ _median_fragment_height(fragments),
548
+ )
549
+ for rule in component
550
+ if rule.orientation == "horizontal"
551
+ }
552
+
553
+
554
+ def _rule_bands_share_grid_tracks(
555
+ top_rule: _LocalAxisLine,
556
+ bottom_rule: _LocalAxisLine,
557
+ axis_lines: list[_LocalAxisLine],
558
+ median_height: float,
559
+ ) -> bool:
560
+ """校验相邻横边界的跨度,并确认其间存在连续外框或稳定列分隔线。"""
561
+
562
+ top_width = max(0.1, top_rule.bbox[2] - top_rule.bbox[0])
563
+ bottom_width = max(0.1, bottom_rule.bbox[2] - bottom_rule.bbox[0])
564
+ overlap_left = max(top_rule.bbox[0], bottom_rule.bbox[0])
565
+ overlap_right = min(top_rule.bbox[2], bottom_rule.bbox[2])
566
+ overlap_width = max(0.0, overlap_right - overlap_left)
567
+ endpoint_tolerance = max(4.0, 2.0 * median_height)
568
+ if (
569
+ overlap_width / max(top_width, bottom_width) < 0.9
570
+ or abs(top_rule.bbox[0] - bottom_rule.bbox[0]) > endpoint_tolerance
571
+ or abs(top_rule.bbox[2] - bottom_rule.bbox[2]) > endpoint_tolerance
572
+ ):
573
+ return False
574
+
575
+ top_y = _bbox_center_y(top_rule.bbox)
576
+ bottom_y = _bbox_center_y(bottom_rule.bbox)
577
+ track_tolerance = max(1.0, 0.25 * median_height)
578
+ raw_positions = [
579
+ _bbox_center_x(line.bbox)
580
+ for line in axis_lines
581
+ if line.orientation == "vertical"
582
+ and line.bbox[1] <= top_y + track_tolerance
583
+ and line.bbox[3] >= bottom_y - track_tolerance
584
+ and overlap_left - track_tolerance <= _bbox_center_x(line.bbox) <= overlap_right + track_tolerance
585
+ ]
586
+ position_groups: list[list[float]] = []
587
+ for position in sorted(raw_positions):
588
+ if position_groups and abs(position - statistics.mean(position_groups[-1])) <= track_tolerance:
589
+ position_groups[-1].append(position)
590
+ else:
591
+ position_groups.append([position])
592
+ positions = [statistics.mean(group) for group in position_groups]
593
+ has_outer_tracks = any(abs(position - overlap_left) <= endpoint_tolerance for position in positions) and any(
594
+ abs(position - overlap_right) <= endpoint_tolerance for position in positions
595
+ )
596
+ interior_tracks = [
597
+ position for position in positions if overlap_left + track_tolerance < position < overlap_right - track_tolerance
598
+ ]
599
+ return has_outer_tracks or len(interior_tracks) >= 2
600
+
601
+
602
+ def _group_long_horizontal_rules(
603
+ axis_lines: list[_LocalAxisLine],
604
+ median_height: float,
605
+ ) -> list[list[_LocalAxisLine]]:
606
+ """按近似左右端点聚合长横线,并去除同位置重复路径。"""
607
+
608
+ minimum_length = max(40.0, 10.0 * median_height)
609
+ horizontal_lines = [
610
+ line for line in axis_lines if line.orientation == "horizontal" and line.bbox[2] - line.bbox[0] >= minimum_length
611
+ ]
612
+ endpoint_tolerance = max(4.0, 2.0 * median_height)
613
+ span_groups: list[list[_LocalAxisLine]] = []
614
+ for line in sorted(horizontal_lines, key=lambda item: (item.bbox[0], item.bbox[2], item.bbox[1])):
615
+ target = next(
616
+ (
617
+ group
618
+ for group in span_groups
619
+ if abs(line.bbox[0] - group[0].bbox[0]) <= endpoint_tolerance
620
+ and abs(line.bbox[2] - group[0].bbox[2]) <= endpoint_tolerance
621
+ ),
622
+ None,
623
+ )
624
+ if target is None:
625
+ span_groups.append([line])
626
+ else:
627
+ target.append(line)
628
+
629
+ output: list[list[_LocalAxisLine]] = []
630
+ for span_group in span_groups:
631
+ unique_lines: list[_LocalAxisLine] = []
632
+ for line in sorted(span_group, key=lambda item: _bbox_center_y(item.bbox)):
633
+ if any(abs(_bbox_center_y(line.bbox) - _bbox_center_y(item.bbox)) <= 1.0 for item in unique_lines):
634
+ continue
635
+ unique_lines.append(line)
636
+ if len(unique_lines) >= 2:
637
+ output.append(unique_lines)
638
+ return output
639
+
640
+
641
+ def _rows_inside_rule_interval(
642
+ rows: list[_VisualRow],
643
+ rule_bbox: BBox,
644
+ excluded_bboxes: list[BBox],
645
+ ) -> list[_VisualRow]:
646
+ """截取边界走廊内文本行,并移除已由强图形核心覆盖的片段。"""
647
+
648
+ output: list[_VisualRow] = []
649
+ for row in rows:
650
+ clipped_row = _clip_visual_row_to_corridor(row, rule_bbox, margin=0.0)
651
+ if clipped_row is None or not rule_bbox[1] <= clipped_row.center_y <= rule_bbox[3]:
652
+ continue
653
+ fragments = [
654
+ fragment
655
+ for fragment in clipped_row.fragments
656
+ if not any(
657
+ _point_in_bbox(
658
+ (
659
+ _bbox_center_x(fragment.local_bbox),
660
+ _bbox_center_y(fragment.local_bbox),
661
+ ),
662
+ excluded_bbox,
663
+ )
664
+ for excluded_bbox in excluded_bboxes
665
+ )
666
+ ]
667
+ if not fragments:
668
+ continue
669
+ output.append(
670
+ _VisualRow(
671
+ fragments=fragments,
672
+ center_y=sum(_bbox_center_y(fragment.local_bbox) for fragment in fragments) / len(fragments),
673
+ bbox=_bbox_union_many([fragment.local_bbox for fragment in fragments]),
674
+ visual_row_id=clipped_row.visual_row_id,
675
+ )
676
+ )
677
+ return output
678
+
679
+
680
+ def _every_rule_interval_has_multi_cell_row(
681
+ rows: list[_VisualRow],
682
+ rule_group: list[_LocalAxisLine],
683
+ median_height: float,
684
+ ) -> bool:
685
+ """要求候选跨过的每个相邻横线区间都存在至少一行多单元文本。"""
686
+
687
+ if len(rule_group) < 2:
688
+ return False
689
+ for interval_index, (top_rule, bottom_rule) in enumerate(zip(rule_group, rule_group[1:])):
690
+ top = _bbox_center_y(top_rule.bbox)
691
+ bottom = _bbox_center_y(bottom_rule.bbox)
692
+ interval_rows = [row for row in rows if top <= row.center_y <= bottom]
693
+ if any(len(row.fragments) >= 2 for row in interval_rows):
694
+ continue
695
+ # 紧邻顶边界的合并表头可能由 pdftext 输出为一个短 fragment;
696
+ # 只放宽高度很小的首区间,避免把远处章节标题接到表格上。
697
+ if interval_index == 0 and interval_rows and bottom - top <= 2.5 * median_height:
698
+ continue
699
+ return False
700
+ return True
701
+
702
+
703
+ def _rule_intervals_are_column_compatible(
704
+ rows: list[_VisualRow],
705
+ rule_group: list[_LocalAxisLine],
706
+ median_height: float,
707
+ ) -> bool:
708
+ """拒绝跨过长篇栏式正文、导致稳定列数明显塌缩的多表合并区间。"""
709
+
710
+ profiles: list[tuple[int, float, int, float]] = []
711
+ for top_rule, bottom_rule in zip(rule_group, rule_group[1:]):
712
+ top = _bbox_center_y(top_rule.bbox)
713
+ bottom = _bbox_center_y(bottom_rule.bbox)
714
+ interval_rows = [row for row in rows if top <= row.center_y <= bottom and len(row.fragments) >= 2]
715
+ stable_columns, column_coverage = _count_stable_columns(
716
+ interval_rows,
717
+ median_height,
718
+ )
719
+ profiles.append(
720
+ (
721
+ stable_columns,
722
+ bottom - top,
723
+ len(interval_rows),
724
+ column_coverage,
725
+ )
726
+ )
727
+ maximum_columns = max(
728
+ (columns for columns, _height, _row_count, _coverage in profiles),
729
+ default=0,
730
+ )
731
+ for interval_index, (columns, interval_height, row_count, coverage) in enumerate(profiles):
732
+ # 紧凑首区间可能只是跨列表头;一旦区间明显高于普通表头,
733
+ # 也必须具有连续多单元格行,不能无条件跨过正文连接两张表。
734
+ if interval_index == 0 and interval_height <= 2.5 * median_height:
735
+ continue
736
+ if interval_height <= 6.0 * median_height:
737
+ continue
738
+ minimum_rows = max(2, int(interval_height / max(8.0 * median_height, 0.1)))
739
+ if row_count < minimum_rows or coverage < 0.5:
740
+ return False
741
+ if columns < max(2, int(0.5 * maximum_columns)):
742
+ return False
743
+ return True
744
+
745
+
746
+ def _continuous_table_row_segments(
747
+ rows: list[_VisualRow],
748
+ median_height: float,
749
+ ) -> list[list[_VisualRow]]:
750
+ """按物理行距切分边界区间,保留单元格换行参与连续性判断。"""
751
+
752
+ segments: list[list[_VisualRow]] = []
753
+ for row in sorted(rows, key=lambda item: item.center_y):
754
+ if not segments or max(0.0, row.bbox[1] - segments[-1][-1].bbox[3]) > 3.0 * median_height:
755
+ segments.append([row])
756
+ else:
757
+ segments[-1].append(row)
758
+ return segments
759
+
760
+
761
+ def _table_segment_reaches_boundaries(
762
+ rows: list[_VisualRow],
763
+ rule_bbox: BBox,
764
+ median_height: float,
765
+ ) -> bool:
766
+ """要求数据行链分别贴近最近的上下边界,排除页眉线和远处章节标题。"""
767
+
768
+ if not rows:
769
+ return False
770
+ maximum_gap = 2.5 * median_height
771
+ top_gap = max(0.0, rows[0].bbox[1] - rule_bbox[1])
772
+ bottom_gap = max(0.0, rule_bbox[3] - rows[-1].bbox[3])
773
+ return top_gap <= maximum_gap and bottom_gap <= maximum_gap
774
+
775
+
776
+ def _table_rows_align_with_rule_span(
777
+ rows: list[_VisualRow],
778
+ rule_bbox: BBox,
779
+ median_height: float,
780
+ ) -> bool:
781
+ """校验数据行总体跨度与横线走廊重叠,拒绝仅在边缘偶遇的多列文本。"""
782
+
783
+ if not rows:
784
+ return False
785
+ rows_bbox = _bbox_union_many([row.bbox for row in rows])
786
+ rule_width = max(0.1, rule_bbox[2] - rule_bbox[0])
787
+ rows_width = max(0.1, rows_bbox[2] - rows_bbox[0])
788
+ overlap = max(
789
+ 0.0,
790
+ min(rows_bbox[2], rule_bbox[2]) - max(rows_bbox[0], rule_bbox[0]),
791
+ )
792
+ return overlap / min(rule_width, rows_width) >= 0.9 and rows_width >= max(8.0 * median_height, 0.25 * rule_width)
793
+
794
+
795
+ def _count_aligned_vertical_rules(
796
+ axis_lines: list[_LocalAxisLine],
797
+ rule_bbox: BBox,
798
+ median_height: float,
799
+ ) -> int:
800
+ """统计贯穿候选主要高度且位于横线跨度内的竖向分隔线。"""
801
+
802
+ required_height = max(4.0 * median_height, 0.5 * (rule_bbox[3] - rule_bbox[1]))
803
+ return sum(
804
+ line.orientation == "vertical"
805
+ and rule_bbox[0] - median_height <= _bbox_center_x(line.bbox) <= rule_bbox[2] + median_height
806
+ and line.bbox[3] - line.bbox[1] >= required_height
807
+ and _bbox_axis_overlap_ratio(line.bbox, rule_bbox, axis="y") >= 0.8
808
+ for line in axis_lines
809
+ )
810
+
811
+
812
+ def _compact_fully_ruled_grid_column_count(
813
+ row_segment: list[_VisualRow],
814
+ dense_rows: list[_VisualRow],
815
+ interval_rules: list[_LocalAxisLine],
816
+ axis_lines: list[_LocalAxisLine],
817
+ rule_bbox: BBox,
818
+ median_height: float,
819
+ ) -> int:
820
+ """以完整横竖边界确认两行紧凑网格,并返回物理列数,失败时返回零。"""
821
+
822
+ rule_height = max(0.1, rule_bbox[3] - rule_bbox[1])
823
+ if len(row_segment) != 2 or len(dense_rows) != 2 or len(interval_rules) < 3 or rule_height > 6.0 * median_height:
824
+ return 0
825
+
826
+ vertical_positions = _full_height_vertical_rule_positions(
827
+ axis_lines,
828
+ rule_bbox,
829
+ median_height,
830
+ )
831
+ if len(vertical_positions) < 3:
832
+ return 0
833
+
834
+ edge_tolerance = max(1.5, 0.25 * median_height)
835
+ left_boundary = min(
836
+ vertical_positions,
837
+ key=lambda position: abs(position - rule_bbox[0]),
838
+ )
839
+ right_boundary = min(
840
+ vertical_positions,
841
+ key=lambda position: abs(position - rule_bbox[2]),
842
+ )
843
+ if (
844
+ abs(left_boundary - rule_bbox[0]) > edge_tolerance
845
+ or abs(right_boundary - rule_bbox[2]) > edge_tolerance
846
+ or right_boundary <= left_boundary
847
+ ):
848
+ return 0
849
+
850
+ grid_boundaries = [position for position in vertical_positions if left_boundary <= position <= right_boundary]
851
+ if len(grid_boundaries) < 3:
852
+ return 0
853
+ grid_intervals = list(zip(grid_boundaries, grid_boundaries[1:]))
854
+
855
+ occupied_columns: list[set[int]] = []
856
+ for row in dense_rows:
857
+ row_columns: list[int] = []
858
+ for fragment in row.fragments:
859
+ fragment_center = _bbox_center_x(fragment.local_bbox)
860
+ matching_columns = [index for index, (left, right) in enumerate(grid_intervals) if left <= fragment_center <= right]
861
+ if len(matching_columns) != 1:
862
+ return 0
863
+ column_index = matching_columns[0]
864
+ if column_index in row_columns:
865
+ return 0
866
+ row_columns.append(column_index)
867
+ if len(row_columns) < 2:
868
+ return 0
869
+ occupied_columns.append(set(row_columns))
870
+
871
+ if len(occupied_columns[0] & occupied_columns[1]) < 2:
872
+ return 0
873
+ return len(grid_intervals)
874
+
875
+
876
+ def _full_height_vertical_rule_positions(
877
+ axis_lines: list[_LocalAxisLine],
878
+ rule_bbox: BBox,
879
+ median_height: float,
880
+ ) -> list[float]:
881
+ """收集覆盖紧凑候选主要高度的竖线中心,并合并同位置重复路径。"""
882
+
883
+ rule_height = max(0.1, rule_bbox[3] - rule_bbox[1])
884
+ raw_positions: list[float] = []
885
+ for line in axis_lines:
886
+ if line.orientation != "vertical":
887
+ continue
888
+ overlap = max(
889
+ 0.0,
890
+ min(line.bbox[3], rule_bbox[3]) - max(line.bbox[1], rule_bbox[1]),
891
+ )
892
+ if (
893
+ overlap / rule_height < 0.8
894
+ or line.bbox[3] - line.bbox[1] < 0.8 * rule_height
895
+ or not rule_bbox[0] - median_height <= _bbox_center_x(line.bbox) <= rule_bbox[2] + median_height
896
+ ):
897
+ continue
898
+ raw_positions.append(_bbox_center_x(line.bbox))
899
+
900
+ deduplicated: list[list[float]] = []
901
+ position_tolerance = max(1.0, 0.1 * median_height)
902
+ for position in sorted(raw_positions):
903
+ if deduplicated and abs(position - statistics.mean(deduplicated[-1])) <= position_tolerance:
904
+ deduplicated[-1].append(position)
905
+ else:
906
+ deduplicated.append([position])
907
+ return [statistics.mean(group) for group in deduplicated]
908
+
909
+
910
+ def _looks_like_page_column_prose(
911
+ rows: list[_VisualRow],
912
+ dense_rows: list[_VisualRow],
913
+ stable_columns: int,
914
+ fill_band_count: int,
915
+ aligned_vertical_count: int,
916
+ rule_bbox: BBox,
917
+ ) -> bool:
918
+ """用双栏占宽率识别夹在远横线间的普通并排正文。"""
919
+
920
+ if stable_columns != 2 or fill_band_count >= 2 or aligned_vertical_count > 0 or len(dense_rows) / len(rows) < 0.55:
921
+ return False
922
+ corridor_width = max(0.1, rule_bbox[2] - rule_bbox[0])
923
+ occupied_ratios = [
924
+ sum(fragment.local_bbox[2] - fragment.local_bbox[0] for fragment in row.fragments) / corridor_width
925
+ for row in dense_rows
926
+ ]
927
+ return statistics.median(occupied_ratios) >= 0.75
928
+
929
+
930
+ def _count_repeated_fill_bands(
931
+ path_infos: list[PDFPathInfo],
932
+ rule_bbox: BBox,
933
+ page_size: tuple[float, float],
934
+ angle: int,
935
+ median_height: float,
936
+ ) -> int:
937
+ """统计区间内左右端点和高度重复的填充行带,并对重叠 Path 去重。"""
938
+
939
+ minimum_width = max(8.0 * median_height, 0.3 * (rule_bbox[2] - rule_bbox[0]))
940
+ candidates: list[BBox] = []
941
+ for path_info in path_infos:
942
+ if path_info.form_depth != 0 or not path_info.fill_visible:
943
+ continue
944
+ bbox = _rotate_bbox_to_upright(path_info.bbox, page_size, angle)
945
+ width = bbox[2] - bbox[0]
946
+ height = bbox[3] - bbox[1]
947
+ if (
948
+ width < minimum_width
949
+ or not 0.25 * median_height <= height <= 3.0 * median_height
950
+ or _bbox_center_y(bbox) < rule_bbox[1]
951
+ or _bbox_center_y(bbox) > rule_bbox[3]
952
+ or _bbox_axis_overlap_ratio(bbox, rule_bbox, axis="x") < 0.8
953
+ ):
954
+ continue
955
+ if any(_bbox_overlap_in_smaller(bbox, item) >= 0.9 for item in candidates):
956
+ continue
957
+ candidates.append(bbox)
958
+
959
+ endpoint_tolerance = max(3.0, median_height)
960
+ groups: list[list[BBox]] = []
961
+ for bbox in candidates:
962
+ target = next(
963
+ (
964
+ group
965
+ for group in groups
966
+ if abs(bbox[0] - group[0][0]) <= endpoint_tolerance
967
+ and abs(bbox[2] - group[0][2]) <= endpoint_tolerance
968
+ and abs((bbox[3] - bbox[1]) - (group[0][3] - group[0][1])) <= endpoint_tolerance
969
+ ),
970
+ None,
971
+ )
972
+ if target is None:
973
+ groups.append([bbox])
974
+ else:
975
+ target.append(bbox)
976
+ return max((len(group) for group in groups), default=0)
977
+
978
+
979
+ def _longest_dense_multi_cell_rows(
980
+ rows: list[_VisualRow],
981
+ median_height: float,
982
+ ) -> list[_VisualRow]:
983
+ """返回行距不超过四倍行高的最长连续多单元格文本段。"""
984
+
985
+ segments: list[list[_VisualRow]] = []
986
+ for row in (item for item in rows if len(item.fragments) >= 2):
987
+ if not segments or row.center_y - segments[-1][-1].center_y > 4.0 * median_height:
988
+ segments.append([row])
989
+ else:
990
+ segments[-1].append(row)
991
+ return max(segments, key=len, default=[])
992
+
993
+
994
+ def _expand_rule_table_candidate(
995
+ rule_group: list[_LocalAxisLine],
996
+ core_rows: list[_VisualRow],
997
+ all_rows: list[_VisualRow],
998
+ all_lines: list[_LineItem],
999
+ page_size: tuple[float, float],
1000
+ angle: int,
1001
+ median_height: float,
1002
+ caption_line: _LineItem | None,
1003
+ ) -> _TableCandidate:
1004
+ """合并横线核心与上下注释,并保留注释的独立行身份。"""
1005
+
1006
+ rule_bbox = _bbox_union_many([line.bbox for line in rule_group])
1007
+ core_line_indices = {fragment.line_index for row in core_rows for fragment in row.fragments}
1008
+ caption_rows = _collect_caption_rows(all_rows, caption_line, rule_bbox, median_height)
1009
+ footnote_rows = _collect_footnote_rows(
1010
+ all_rows,
1011
+ all_lines,
1012
+ rule_bbox,
1013
+ median_height,
1014
+ core_line_indices,
1015
+ page_size,
1016
+ angle,
1017
+ )
1018
+ core_local_bbox = _bbox_union(rule_bbox, _bbox_union_many([row.bbox for row in core_rows]))
1019
+ caption_annotation = _build_table_annotation(
1020
+ "caption",
1021
+ caption_rows,
1022
+ excluded_line_indices=core_line_indices,
1023
+ excluded_local_bbox=core_local_bbox,
1024
+ )
1025
+ footnote_annotation = _build_table_annotation(
1026
+ "footnote",
1027
+ footnote_rows,
1028
+ excluded_line_indices=core_line_indices,
1029
+ )
1030
+ annotations = [annotation for annotation in (caption_annotation, footnote_annotation) if annotation is not None]
1031
+ annotation_line_indices = (
1032
+ set().union(
1033
+ *(annotation.line_indices for annotation in annotations),
1034
+ )
1035
+ if annotations
1036
+ else set()
1037
+ )
1038
+ included_rows = [*caption_rows, *core_rows, *footnote_rows]
1039
+ local_bbox = _bbox_union(core_local_bbox, _bbox_union_many([row.bbox for row in included_rows]))
1040
+ return _TableCandidate(
1041
+ bbox=_rotate_bbox_from_upright(local_bbox, page_size, angle),
1042
+ local_bbox=local_bbox,
1043
+ angle=angle,
1044
+ score=0.0,
1045
+ core_bbox=_rotate_bbox_from_upright(core_local_bbox, page_size, angle),
1046
+ # 表体成员与注释成员保持互斥;物化失败时会显式把无效注释放回表体投影。
1047
+ line_indices=core_line_indices - annotation_line_indices,
1048
+ annotations=annotations,
1049
+ )
1050
+
1051
+
1052
+ def _count_stable_columns(
1053
+ rows: list[_VisualRow],
1054
+ median_height: float,
1055
+ ) -> tuple[int, float]:
1056
+ """分别聚类片段左边界、中心和右边界,返回最稳定的列分布。"""
1057
+
1058
+ tolerance = max(3.0, median_height * 0.75)
1059
+ best_result = (0, 0.0)
1060
+ # 三种对齐方式分别聚类,避免把同一片段的不同锚点混算为多列。
1061
+ for alignment in ("left", "center", "right"):
1062
+ clusters: list[dict[str, Any]] = []
1063
+ for row_index, row in enumerate(rows):
1064
+ for fragment in row.fragments:
1065
+ left, _top, right, _bottom = fragment.local_bbox
1066
+ if alignment == "left":
1067
+ anchor = left
1068
+ elif alignment == "center":
1069
+ anchor = (left + right) / 2
1070
+ else:
1071
+ anchor = right
1072
+ cluster = next(
1073
+ (item for item in clusters if abs(anchor - float(item["mean"])) <= tolerance),
1074
+ None,
1075
+ )
1076
+ if cluster is None:
1077
+ clusters.append({"mean": anchor, "values": [anchor], "rows": {row_index}})
1078
+ else:
1079
+ cluster["values"].append(anchor)
1080
+ cluster["rows"].add(row_index)
1081
+ cluster["mean"] = sum(cluster["values"]) / len(cluster["values"])
1082
+ stable_coverages = [len(cluster["rows"]) / len(rows) for cluster in clusters if len(cluster["rows"]) / len(rows) >= 0.5]
1083
+ result = (
1084
+ len(stable_coverages),
1085
+ min(stable_coverages) if stable_coverages else 0.0,
1086
+ )
1087
+ # 仅在结果严格更优时更新,平局时保留既有的左对齐优先级。
1088
+ if result > best_result:
1089
+ best_result = result
1090
+ return best_result
1091
+
1092
+
1093
+ def _merge_table_candidates(candidates: list[_TableCandidate]) -> list[_TableCandidate]:
1094
+ """合并同方向且明显重叠的横线候选,避免同一表格重复输出。"""
1095
+
1096
+ merged: list[_TableCandidate] = []
1097
+ for candidate in sorted(candidates, key=lambda item: item.score, reverse=True):
1098
+ target = next(
1099
+ (
1100
+ item
1101
+ for item in merged
1102
+ if item.angle == candidate.angle and _bbox_overlap_in_smaller(candidate.bbox, item.bbox) >= 0.2
1103
+ ),
1104
+ None,
1105
+ )
1106
+ if target is None:
1107
+ merged.append(candidate)
1108
+ continue
1109
+ target.bbox = _bbox_union(target.bbox, candidate.bbox)
1110
+ target.local_bbox = _bbox_union(target.local_bbox, candidate.local_bbox)
1111
+ if target.core_bbox is None:
1112
+ target.core_bbox = candidate.core_bbox
1113
+ elif candidate.core_bbox is not None:
1114
+ target.core_bbox = _bbox_union(target.core_bbox, candidate.core_bbox)
1115
+ target.line_indices.update(candidate.line_indices)
1116
+ _merge_table_candidate_annotations(target, candidate)
1117
+ target.score = max(target.score, candidate.score)
1118
+ return sorted(merged, key=lambda item: (item.bbox[1], item.bbox[0]))
1119
+
1120
+
1121
+ def _median_fragment_height(fragments: list[_Fragment]) -> float:
1122
+ """返回正向文本片段高度的中位数。"""
1123
+
1124
+ heights = [
1125
+ fragment.local_bbox[3] - fragment.local_bbox[1]
1126
+ for fragment in fragments
1127
+ if fragment.local_bbox[3] > fragment.local_bbox[1]
1128
+ ]
1129
+ return max(0.1, float(statistics.median(heights)) if heights else 1.0)