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,1106 @@
1
+ """提供文本栏带、行距和行连接的共享布局判定。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ import statistics
8
+ from typing import Sequence
9
+
10
+
11
+ from ....foundation.text import is_hyphen_at_line_end
12
+ from ....schema import BBox
13
+
14
+ from .typography import _normalized_font_family
15
+ from .models import _LineItem, _LocalAxisLine, _TextLane
16
+ from .geometry import (
17
+ _bbox_axis_overlap_ratio,
18
+ _bbox_center_x,
19
+ _bbox_center_y,
20
+ _bbox_union_many,
21
+ _clip_bbox,
22
+ _bbox_intersects,
23
+ _coerce_bbox,
24
+ )
25
+
26
+
27
+ _TIGHT_OUTPUT_PADDING = 1.0
28
+
29
+
30
+ def _title_fonts_compatible(first: _LineItem, second: _LineItem) -> bool:
31
+ """检查标题字体和字重是否兼容;低字体覆盖率时仍保留可靠字重证据。"""
32
+
33
+ font_conflicts = (
34
+ first.font_signature is not None
35
+ and second.font_signature is not None
36
+ and first.font_coverage >= 0.75
37
+ and second.font_coverage >= 0.75
38
+ and first.font_signature != second.font_signature
39
+ )
40
+ weight_conflicts = _font_weights_conflict(first, second)
41
+ return not (font_conflicts or weight_conflicts)
42
+
43
+
44
+ def _font_signatures_share_family(
45
+ first: tuple[str, int] | None,
46
+ second: tuple[str, int] | None,
47
+ ) -> bool:
48
+ """判断两个可靠字体签名是否仅因 PDF 子集前缀或描述标志不同。"""
49
+
50
+ first_family = _normalized_font_family(first)
51
+ second_family = _normalized_font_family(second)
52
+ return first_family is not None and second_family is not None and first_family == second_family
53
+
54
+
55
+ def _font_weights_conflict(first: _LineItem, second: _LineItem) -> bool:
56
+ """判断两行是否存在足以构成段落硬边界的显著字重差异。"""
57
+
58
+ return (
59
+ first.dominant_font_weight is not None
60
+ and second.dominant_font_weight is not None
61
+ and abs(first.dominant_font_weight - second.dominant_font_weight) >= 100.0
62
+ and max(first.dominant_font_weight, second.dominant_font_weight)
63
+ >= 1.15 * min(first.dominant_font_weight, second.dominant_font_weight)
64
+ )
65
+
66
+
67
+ def _should_connect_semantic_rows(
68
+ previous: tuple[_LineItem, BBox],
69
+ current: tuple[_LineItem, BBox],
70
+ lane: _TextLane,
71
+ regular_gap: float,
72
+ table_bboxes: list[BBox],
73
+ axis_lines: list[_LocalAxisLine],
74
+ ) -> bool:
75
+ """只用几何、字体和障碍连接同类型语义行,避免标题内容影响聚合。"""
76
+
77
+ previous_line, previous_bbox = previous
78
+ current_line, current_bbox = current
79
+ if previous_line.semantic_type != current_line.semantic_type:
80
+ return False
81
+ if _connection_crosses_table(previous_line.bbox, current_line.bbox, table_bboxes):
82
+ return False
83
+ if _horizontal_rule_separates_rows(previous_bbox, current_bbox, lane, axis_lines):
84
+ return False
85
+ previous_height = _line_effective_height(previous_line, previous_bbox)
86
+ current_height = _line_effective_height(current_line, current_bbox)
87
+ pair_height = max(previous_height, current_height)
88
+ if max(previous_height, current_height) / min(previous_height, current_height) > 1.35:
89
+ return False
90
+ vertical_gap = _effective_text_row_gap(previous, current)
91
+ if not -0.25 * pair_height <= vertical_gap <= max(1.25 * pair_height, regular_gap + 0.75 * pair_height):
92
+ return False
93
+ if previous_line.semantic_type == "paragraph_title" and vertical_gap > 0.5 * pair_height:
94
+ return False
95
+ font_conflicts = (
96
+ previous_line.font_signature is not None
97
+ and current_line.font_signature is not None
98
+ and previous_line.font_coverage >= 0.75
99
+ and current_line.font_coverage >= 0.75
100
+ and previous_line.font_signature != current_line.font_signature
101
+ )
102
+ uncertain_document_title_font = (
103
+ previous_line.semantic_type == "doc_title"
104
+ and min(previous_line.font_coverage, current_line.font_coverage) < 0.85
105
+ and (
106
+ previous_line.dominant_font_weight is None
107
+ or current_line.dominant_font_weight is None
108
+ or abs(previous_line.dominant_font_weight - current_line.dominant_font_weight) < 100.0
109
+ )
110
+ )
111
+ if font_conflicts and not uncertain_document_title_font:
112
+ return False
113
+ lane_width = max(0.1, lane.right - lane.left)
114
+ centered_pair = abs(_bbox_center_x(previous_bbox) - _bbox_center_x(current_bbox)) <= 0.15 * lane_width
115
+ aligned_pair = abs(previous_bbox[0] - current_bbox[0]) <= 0.75 * pair_height
116
+ overlapping_pair = _bbox_axis_overlap_ratio(previous_bbox, current_bbox, axis="x") >= 0.35
117
+ return centered_pair or aligned_pair or overlapping_pair
118
+
119
+
120
+ def _line_style_scale(line: _LineItem, local_bbox: BBox) -> float:
121
+ """返回 canonical 字体尺度,缺失时兼容旧有效行高与局部 bbox。"""
122
+
123
+ return max(
124
+ 0.1,
125
+ line.em_height
126
+ if line.style_scale_repaired and line.em_height > 0
127
+ else line.effective_height or (local_bbox[3] - local_bbox[1]),
128
+ )
129
+
130
+
131
+ def _line_canonical_style_scale(line: _LineItem, local_bbox: BBox) -> float:
132
+ """忽略语义选择标记,直接返回 tight/origin 校准后的字体尺度。"""
133
+
134
+ return max(
135
+ 0.1,
136
+ line.em_height if line.em_height > 0 else line.effective_height or (local_bbox[3] - local_bbox[1]),
137
+ )
138
+
139
+
140
+ def _line_effective_height(line: _LineItem, local_bbox: BBox) -> float:
141
+ """兼容既有布局调用,并统一转发到 canonical 字体尺度。"""
142
+
143
+ return _line_style_scale(line, local_bbox)
144
+
145
+
146
+ def _line_layout_height(_line: _LineItem, local_bbox: BBox) -> float:
147
+ """返回 canonical 布局包络高度,供公式与视觉容器空间判断使用。"""
148
+
149
+ return max(0.1, local_bbox[3] - local_bbox[1])
150
+
151
+
152
+ def _line_tight_output_bbox(
153
+ line: _LineItem,
154
+ page_size: tuple[float, float],
155
+ ) -> BBox | None:
156
+ """把可靠 tight 字形并集四边各扩 1pt,并裁剪到页面范围。"""
157
+
158
+ ink_bbox = _coerce_bbox(line.ink_bbox)
159
+ if ink_bbox is None:
160
+ return None
161
+ return _clip_bbox(
162
+ (
163
+ ink_bbox[0] - _TIGHT_OUTPUT_PADDING,
164
+ ink_bbox[1] - _TIGHT_OUTPUT_PADDING,
165
+ ink_bbox[2] + _TIGHT_OUTPUT_PADDING,
166
+ ink_bbox[3] + _TIGHT_OUTPUT_PADDING,
167
+ ),
168
+ page_size,
169
+ )
170
+
171
+
172
+ def _lines_tight_output_bbox(
173
+ lines: Sequence[_LineItem],
174
+ page_size: tuple[float, float],
175
+ ) -> BBox | None:
176
+ """合并多行 tight+1pt 候选;缺失 tight 的成员继续使用原 layout bbox。"""
177
+
178
+ output_bboxes: list[BBox] = []
179
+ changed = False
180
+ for line in lines:
181
+ candidate = _line_tight_output_bbox(line, page_size)
182
+ output_bboxes.append(candidate or line.bbox)
183
+ changed = changed or candidate is not None
184
+ if not changed or not output_bboxes:
185
+ return None
186
+ return _bbox_union_many(output_bboxes)
187
+
188
+
189
+ def _effective_text_row_gap(
190
+ previous: tuple[_LineItem, BBox],
191
+ current: tuple[_LineItem, BBox],
192
+ ) -> float:
193
+ """按前一行顶边与有效行高计算净空,避免高数学字形拉长 bbox 底边。"""
194
+
195
+ previous_line, previous_bbox = previous
196
+ _current_line, current_bbox = current
197
+ if previous_line.restored_inline_cluster:
198
+ # 二维文本簇的 bbox 底边是真实分母边界;同时截断深度重叠,避免相邻分式互相成为段落屏障。
199
+ return max(
200
+ current_bbox[1] - previous_bbox[3],
201
+ -0.25 * _line_effective_height(previous_line, previous_bbox),
202
+ )
203
+ return current_bbox[1] - (previous_bbox[1] + _line_effective_height(previous_line, previous_bbox))
204
+
205
+
206
+ def _effective_body_text_row_gap(
207
+ previous: tuple[_LineItem, BBox],
208
+ current: tuple[_LineItem, BBox],
209
+ ) -> float:
210
+ """正文连接优先使用 origin 基线节奏,缺证据时回退既有 bbox 净空。"""
211
+
212
+ previous_line, previous_bbox = previous
213
+ current_line, _current_bbox = current
214
+ if (
215
+ previous_line.baseline is not None
216
+ and current_line.baseline is not None
217
+ and current_line.baseline > previous_line.baseline
218
+ ):
219
+ previous_scale = _line_effective_height(previous_line, previous_bbox)
220
+ current_scale = _line_effective_height(*current)
221
+ pitch = current_line.baseline - previous_line.baseline
222
+ if (
223
+ 0.5 * min(previous_scale, current_scale)
224
+ <= pitch
225
+ <= 3.0
226
+ * max(
227
+ previous_scale,
228
+ current_scale,
229
+ )
230
+ ):
231
+ return pitch - previous_scale
232
+ return _effective_text_row_gap(previous, current)
233
+
234
+
235
+ def _infer_text_lanes(
236
+ line_geometry: list[tuple[_LineItem, BBox]],
237
+ local_page_width: float,
238
+ median_height: float,
239
+ *,
240
+ recalculate_intervals: bool = True,
241
+ ) -> list[_TextLane]:
242
+ """从重复左右边缘推断稳定栏带,并按需用已分配成员重算边界。"""
243
+
244
+ anchor_tolerance = max(3.0, 0.75 * median_height)
245
+ anchor_geometry = [
246
+ item for item in line_geometry if item[0].semantic_type not in {"header", "footer", "page_number", "aside_text"}
247
+ ]
248
+ regular_lines = [
249
+ item
250
+ for item in anchor_geometry
251
+ if item[1][2] - item[1][0] >= max(4.0 * _line_effective_height(*item), 0.15 * local_page_width)
252
+ ]
253
+ left_clusters: list[list[tuple[_LineItem, BBox]]] = []
254
+ for item in sorted(regular_lines, key=lambda value: value[1][0]):
255
+ if not left_clusters:
256
+ left_clusters.append([item])
257
+ continue
258
+ cluster_left = statistics.median(member[1][0] for member in left_clusters[-1])
259
+ if abs(item[1][0] - cluster_left) <= anchor_tolerance:
260
+ left_clusters[-1].append(item)
261
+ else:
262
+ left_clusters.append([item])
263
+
264
+ supported_intervals = [
265
+ (
266
+ statistics.median(item[1][0] for item in cluster),
267
+ statistics.median(item[1][2] for item in cluster),
268
+ len(cluster),
269
+ )
270
+ for cluster in left_clusters
271
+ if len(cluster) >= 3
272
+ ]
273
+ supported_intervals.sort(key=lambda interval: interval[0])
274
+ filtered_intervals: list[tuple[float, float, int]] = []
275
+ for interval in supported_intervals:
276
+ if not filtered_intervals:
277
+ filtered_intervals.append(interval)
278
+ continue
279
+ previous = filtered_intervals[-1]
280
+ minimum_gutter = max(6.0, 0.75 * median_height)
281
+ if interval[0] - previous[1] >= minimum_gutter:
282
+ filtered_intervals.append(interval)
283
+ elif interval[2] > previous[2]:
284
+ filtered_intervals[-1] = interval
285
+
286
+ if not filtered_intervals:
287
+ source = regular_lines or line_geometry
288
+ filtered_intervals = [
289
+ (
290
+ min(item[1][0] for item in source),
291
+ max(item[1][2] for item in source),
292
+ len(source),
293
+ )
294
+ ]
295
+
296
+ nested_column_band = None
297
+ if len(filtered_intervals) == 1 and anchor_geometry:
298
+ nested_outer_interval = (
299
+ (
300
+ min(item[1][0] for item in anchor_geometry),
301
+ max(item[1][2] for item in anchor_geometry),
302
+ )
303
+ if recalculate_intervals
304
+ else filtered_intervals[0][:2]
305
+ )
306
+ nested_column_band = _infer_nested_column_band(
307
+ anchor_geometry,
308
+ local_page_width,
309
+ median_height,
310
+ nested_outer_interval,
311
+ enhanced=recalculate_intervals,
312
+ )
313
+ if nested_column_band is not None:
314
+ nested_lanes, band_top, band_bottom = nested_column_band
315
+ fallback_lane = _TextLane(
316
+ left=filtered_intervals[0][0],
317
+ right=filtered_intervals[0][1],
318
+ )
319
+ span_lines: list[tuple[_LineItem, BBox]] = []
320
+ for item in line_geometry:
321
+ line, bbox = item
322
+ center_y = _bbox_center_y(bbox)
323
+ if (
324
+ line.semantic_type in {"header", "footer", "page_number", "page_footnote", "aside_text"}
325
+ or not band_top <= center_y <= band_bottom
326
+ ):
327
+ fallback_lane.lines.append(item)
328
+ continue
329
+ line_width = max(0.1, bbox[2] - bbox[0])
330
+ scored_lanes = [
331
+ (
332
+ max(0.0, min(bbox[2], lane.right) - max(bbox[0], lane.left)) / line_width,
333
+ lane,
334
+ )
335
+ for lane in nested_lanes
336
+ ]
337
+ coverage_scores = sorted(
338
+ (coverage for coverage, _lane in scored_lanes),
339
+ reverse=True,
340
+ )
341
+ best_coverage, best_lane = max(scored_lanes, key=lambda value: value[0])
342
+ fits_only_one_lane = _fits_only_one_lane(
343
+ bbox,
344
+ best_lane,
345
+ nested_lanes,
346
+ anchor_tolerance,
347
+ )
348
+ if len(coverage_scores) > 1 and coverage_scores[1] >= 0.2 and not fits_only_one_lane:
349
+ span_lines.append(item)
350
+ continue
351
+ if best_coverage >= 0.5 or fits_only_one_lane:
352
+ best_lane.lines.append(item)
353
+ else:
354
+ span_lines.append(item)
355
+ lanes = [lane for lane in nested_lanes if lane.lines]
356
+ if recalculate_intervals:
357
+ _expand_nested_lane_intervals_from_members(
358
+ lanes,
359
+ anchor_tolerance,
360
+ )
361
+ if fallback_lane.lines:
362
+ lanes.append(fallback_lane)
363
+ if span_lines:
364
+ lanes.append(
365
+ _TextLane(
366
+ left=min(item[1][0] for item in span_lines),
367
+ right=max(item[1][2] for item in span_lines),
368
+ lines=span_lines,
369
+ is_span=True,
370
+ )
371
+ )
372
+ _reattach_span_lane_continuations(lanes, median_height)
373
+ _reattach_cross_lane_short_tails(lanes, median_height)
374
+ return lanes
375
+
376
+ lanes = [_TextLane(left=left, right=right) for left, right, _support in filtered_intervals]
377
+ span_lines: list[tuple[_LineItem, BBox]] = []
378
+ for item in line_geometry:
379
+ bbox = item[1]
380
+ line_width = max(0.1, bbox[2] - bbox[0])
381
+ scored_lanes = [
382
+ (
383
+ max(0.0, min(bbox[2], lane.right) - max(bbox[0], lane.left)) / line_width,
384
+ lane,
385
+ )
386
+ for lane in lanes
387
+ ]
388
+ coverage_scores = sorted(
389
+ (coverage for coverage, _lane in scored_lanes),
390
+ reverse=True,
391
+ )
392
+ best_coverage, best_lane = max(scored_lanes, key=lambda value: value[0])
393
+ fits_only_one_lane = _fits_only_one_lane(
394
+ bbox,
395
+ best_lane,
396
+ lanes,
397
+ anchor_tolerance,
398
+ )
399
+ # 同时覆盖两个稳定正文栏的行仍属于跨栏内容;只进入单侧栏且未越过栏沟的
400
+ # 宽正文行则回到该栏,避免窄图注把正文错误挤入 span lane。
401
+ if len(coverage_scores) > 1 and coverage_scores[1] >= 0.2 and not fits_only_one_lane:
402
+ span_lines.append(item)
403
+ continue
404
+ if len(lanes) == 1 or fits_only_one_lane:
405
+ best_lane.lines.append(item)
406
+ else:
407
+ span_lines.append(item)
408
+
409
+ if recalculate_intervals:
410
+ _expand_nested_lane_intervals_from_members(
411
+ lanes,
412
+ anchor_tolerance,
413
+ )
414
+ if span_lines:
415
+ lanes.append(
416
+ _TextLane(
417
+ left=min(item[1][0] for item in span_lines),
418
+ right=max(item[1][2] for item in span_lines),
419
+ lines=span_lines,
420
+ is_span=True,
421
+ )
422
+ )
423
+ _reattach_span_lane_continuations(lanes, median_height)
424
+ _reattach_cross_lane_short_tails(lanes, median_height)
425
+ return lanes
426
+
427
+
428
+ def _fits_only_one_lane(
429
+ bbox: BBox,
430
+ best_lane: _TextLane,
431
+ lanes: list[_TextLane],
432
+ tolerance: float,
433
+ ) -> bool:
434
+ """判断宽行是否仍完整停留在某一栏及其栏沟边界以内。"""
435
+
436
+ ordered = sorted(lanes, key=lambda lane: lane.left)
437
+ lane_index = ordered.index(best_lane)
438
+ if lane_index > 0 and bbox[0] < ordered[lane_index - 1].right - tolerance:
439
+ return False
440
+ if lane_index + 1 < len(ordered) and bbox[2] > ordered[lane_index + 1].left - 0.25 * tolerance:
441
+ return False
442
+ return best_lane.left - tolerance <= _bbox_center_x(bbox) <= best_lane.right + max(tolerance, bbox[2] - best_lane.right)
443
+
444
+
445
+ def _expand_nested_lane_intervals_from_members(
446
+ lanes: list[_TextLane],
447
+ tolerance: float,
448
+ ) -> None:
449
+ """按已归属成员扩展局部栏边界,并在相邻栏相交前保留稳定栏沟。"""
450
+
451
+ for lane in lanes:
452
+ alignment_tolerance = max(1.0, 0.5 * tolerance)
453
+ body_members = [bbox for line, bbox in lane.lines if line.semantic_type is None]
454
+ if not body_members:
455
+ continue
456
+ aligned_members = [
457
+ bbox
458
+ for bbox in body_members
459
+ if abs(bbox[0] - lane.left) <= alignment_tolerance or abs(bbox[2] - lane.right) <= alignment_tolerance
460
+ ]
461
+ if not aligned_members:
462
+ continue
463
+ # 页眉、页码和标题不参与;同时只让至少一侧锚点稳定的正文扩张栏宽,
464
+ # 避免页面后续另一版式区段把当前局部栏整体拉宽。
465
+ lane.left = min(lane.left, min(bbox[0] for bbox in aligned_members))
466
+ lane.right = max(lane.right, max(bbox[2] for bbox in aligned_members))
467
+ ordered = sorted(lanes, key=lambda lane: lane.left)
468
+ for left_lane, right_lane in zip(ordered, ordered[1:]):
469
+ if left_lane.right < right_lane.left - tolerance:
470
+ continue
471
+ midpoint = (left_lane.right + right_lane.left) / 2.0
472
+ left_lane.right = min(left_lane.right, midpoint)
473
+ right_lane.left = max(right_lane.left, midpoint)
474
+
475
+
476
+ def _infer_nested_column_band(
477
+ line_geometry: list[tuple[_LineItem, BBox]],
478
+ local_page_width: float,
479
+ median_height: float,
480
+ outer_interval: tuple[float, float],
481
+ *,
482
+ enhanced: bool = False,
483
+ ) -> tuple[list[_TextLane], float, float] | None:
484
+ """在全宽版心内查找仅占局部纵向区间的并列正文栏。"""
485
+
486
+ outer_width = max(0.1, outer_interval[1] - outer_interval[0])
487
+ candidates = [
488
+ item
489
+ for item in line_geometry
490
+ if item[0].semantic_type is None
491
+ and max(
492
+ 4.0 * _line_effective_height(*item),
493
+ 0.12 * local_page_width,
494
+ )
495
+ <= item[1][2] - item[1][0]
496
+ <= 0.62 * outer_width
497
+ ]
498
+ if len(candidates) < 6:
499
+ return None
500
+
501
+ center_tolerance = max(2.0 * median_height, 0.06 * local_page_width)
502
+ center_clusters: list[list[tuple[_LineItem, BBox]]] = []
503
+ for item in sorted(candidates, key=lambda value: _bbox_center_x(value[1])):
504
+ center = _bbox_center_x(item[1])
505
+ target = next(
506
+ (
507
+ cluster
508
+ for cluster in center_clusters
509
+ if abs(center - statistics.median(_bbox_center_x(member[1]) for member in cluster)) <= center_tolerance
510
+ ),
511
+ None,
512
+ )
513
+ if target is None:
514
+ center_clusters.append([item])
515
+ else:
516
+ target.append(item)
517
+
518
+ supported = [cluster for cluster in center_clusters if len(cluster) >= 3]
519
+ supported.sort(key=lambda cluster: statistics.median(_bbox_center_x(item[1]) for item in cluster))
520
+ best_pair: (
521
+ tuple[
522
+ tuple[int, float, float],
523
+ list[tuple[_LineItem, BBox]],
524
+ list[tuple[_LineItem, BBox]],
525
+ tuple[float, float],
526
+ tuple[float, float],
527
+ ]
528
+ | None
529
+ ) = None
530
+ cluster_pairs = (
531
+ [
532
+ (left_cluster, right_cluster)
533
+ for left_index, left_cluster in enumerate(supported[:-1])
534
+ for right_cluster in supported[left_index + 1 :]
535
+ ]
536
+ if enhanced
537
+ else list(zip(supported, supported[1:]))
538
+ )
539
+ for left_cluster, right_cluster in cluster_pairs:
540
+ left_interval = (
541
+ statistics.median(item[1][0] for item in left_cluster),
542
+ statistics.median(item[1][2] for item in left_cluster),
543
+ )
544
+ right_interval = (
545
+ statistics.median(item[1][0] for item in right_cluster),
546
+ statistics.median(item[1][2] for item in right_cluster),
547
+ )
548
+ gutter = right_interval[0] - left_interval[1]
549
+ common_top = max(
550
+ min(item[1][1] for item in left_cluster),
551
+ min(item[1][1] for item in right_cluster),
552
+ )
553
+ common_bottom = min(
554
+ max(item[1][3] for item in left_cluster),
555
+ max(item[1][3] for item in right_cluster),
556
+ )
557
+ combined_width = right_interval[1] - left_interval[0]
558
+ if (
559
+ gutter < max(6.0, 0.75 * median_height)
560
+ or common_bottom - common_top < 2.0 * median_height
561
+ or combined_width < 0.55 * outer_width
562
+ ):
563
+ continue
564
+ score = (
565
+ min(len(left_cluster), len(right_cluster)),
566
+ common_bottom - common_top,
567
+ gutter,
568
+ )
569
+ candidate_pair = (
570
+ score,
571
+ left_cluster,
572
+ right_cluster,
573
+ left_interval,
574
+ right_interval,
575
+ )
576
+ if best_pair is None or candidate_pair[0] > best_pair[0]:
577
+ best_pair = candidate_pair
578
+ if best_pair is None:
579
+ return None
580
+
581
+ _score, left_cluster, right_cluster, left_interval, right_interval = best_pair
582
+ band_top = min(item[1][1] for item in [*left_cluster, *right_cluster]) - 0.5 * median_height
583
+ band_bottom = max(item[1][3] for item in [*left_cluster, *right_cluster]) + 0.5 * median_height
584
+ return (
585
+ [
586
+ _TextLane(left=left_interval[0], right=left_interval[1]),
587
+ _TextLane(left=right_interval[0], right=right_interval[1]),
588
+ ],
589
+ band_top,
590
+ band_bottom,
591
+ )
592
+
593
+
594
+ def _reattach_span_lane_continuations(
595
+ lanes: list[_TextLane],
596
+ median_height: float,
597
+ ) -> None:
598
+ """把紧随稳定跨栏多行之后的单栏宽短尾行迁回对应 span lane。"""
599
+
600
+ regular_lanes = [lane for lane in lanes if not lane.is_span]
601
+ span_lanes = [lane for lane in lanes if lane.is_span]
602
+ if len(regular_lanes) < 2 or not span_lanes:
603
+ return
604
+
605
+ for span_lane in span_lanes:
606
+ _reattach_repeated_indented_span_tails(
607
+ span_lane,
608
+ regular_lanes,
609
+ median_height,
610
+ )
611
+ while True:
612
+ span_lane.lines.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
613
+ candidates: list[
614
+ tuple[
615
+ float,
616
+ float,
617
+ _TextLane,
618
+ tuple[_LineItem, BBox],
619
+ ]
620
+ ] = []
621
+ for regular_lane in regular_lanes:
622
+ for candidate in regular_lane.lines:
623
+ candidate_line, candidate_bbox = candidate
624
+ preceding = [
625
+ item
626
+ for item in span_lane.lines
627
+ if item[0].semantic_type == candidate_line.semantic_type and item[1][1] < candidate_bbox[1]
628
+ ]
629
+ if len(preceding) < 2:
630
+ continue
631
+ previous, last = preceding[-2:]
632
+ previous_height = _line_effective_height(*previous)
633
+ last_height = _line_effective_height(*last)
634
+ if (
635
+ abs(previous[1][0] - last[1][0]) > 0.75 * median_height
636
+ or max(previous_height, last_height) / min(previous_height, last_height) > 1.35
637
+ or not _title_fonts_compatible(previous[0], last[0])
638
+ or not -0.25 * median_height <= _effective_text_row_gap(previous, last) <= 0.75 * median_height
639
+ ):
640
+ continue
641
+ gap = _effective_text_row_gap(last, candidate)
642
+ candidate_height = _line_effective_height(*candidate)
643
+ if (
644
+ not -0.25 * median_height <= gap <= 0.75 * median_height
645
+ or abs(candidate_bbox[0] - last[1][0]) > 0.75 * median_height
646
+ or max(last_height, candidate_height) / min(last_height, candidate_height) > 1.35
647
+ or not _title_fonts_compatible(last[0], candidate_line)
648
+ ):
649
+ continue
650
+ has_parallel_peer = any(
651
+ other_line is not candidate_line
652
+ and _bbox_axis_overlap_ratio(
653
+ candidate_bbox,
654
+ other_bbox,
655
+ axis="y",
656
+ )
657
+ >= 0.5
658
+ for lane in regular_lanes
659
+ for other_line, other_bbox in lane.lines
660
+ )
661
+ if has_parallel_peer:
662
+ continue
663
+ candidates.append(
664
+ (
665
+ candidate_bbox[1],
666
+ max(0.0, gap),
667
+ regular_lane,
668
+ candidate,
669
+ )
670
+ )
671
+ if not candidates:
672
+ break
673
+ _top, _gap, regular_lane, candidate = min(
674
+ candidates,
675
+ key=lambda item: (item[0], item[1]),
676
+ )
677
+ regular_lane.lines.remove(candidate)
678
+ span_lane.lines.append(candidate)
679
+ span_lane.left = min(span_lane.left, candidate[1][0])
680
+ span_lane.right = max(span_lane.right, candidate[1][2])
681
+ span_lane.lines.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
682
+
683
+
684
+ def _reattach_cross_lane_short_tails(
685
+ lanes: list[_TextLane],
686
+ median_height: float,
687
+ ) -> None:
688
+ """把误入另一栏带、但完整落在唯一前序栏内的正文短尾迁回原栏。"""
689
+
690
+ while True:
691
+ moves: list[
692
+ tuple[
693
+ float,
694
+ _TextLane,
695
+ _TextLane,
696
+ tuple[_LineItem, BBox],
697
+ ]
698
+ ] = []
699
+ for source_lane in lanes:
700
+ for candidate in source_lane.lines:
701
+ candidate_line, candidate_bbox = candidate
702
+ if candidate_line.semantic_type is not None:
703
+ continue
704
+ matches: list[tuple[float, _TextLane]] = []
705
+ for target_lane in lanes:
706
+ if target_lane is source_lane or not target_lane.lines:
707
+ continue
708
+ preceding = [
709
+ item
710
+ for item in target_lane.lines
711
+ if item[0].semantic_type == candidate_line.semantic_type and item[1][1] < candidate_bbox[1]
712
+ ]
713
+ if not preceding:
714
+ continue
715
+ previous = max(
716
+ preceding,
717
+ key=lambda item: (item[1][1], item[1][0]),
718
+ )
719
+ previous_line, previous_bbox = previous
720
+ pair_height = max(
721
+ _line_effective_height(*previous),
722
+ _line_effective_height(*candidate),
723
+ median_height,
724
+ )
725
+ lane_width = max(0.1, target_lane.right - target_lane.left)
726
+ if (
727
+ previous_bbox[2] - previous_bbox[0] < 0.65 * lane_width
728
+ or candidate_bbox[2] - candidate_bbox[0] > 0.85 * lane_width
729
+ or candidate_bbox[0] < target_lane.left - 0.75 * pair_height
730
+ or candidate_bbox[2] > target_lane.right + 0.75 * pair_height
731
+ or abs(candidate_bbox[0] - previous_bbox[0]) > 0.75 * pair_height
732
+ or not _title_fonts_compatible(previous_line, candidate_line)
733
+ ):
734
+ continue
735
+ gap = _effective_body_text_row_gap(previous, candidate)
736
+ if not -0.25 * pair_height <= gap <= 0.9 * pair_height:
737
+ continue
738
+ if (
739
+ previous_line.visual_row_id is not None
740
+ and candidate_line.visual_row_id is not None
741
+ and not 0 < candidate_line.visual_row_id - previous_line.visual_row_id <= 2
742
+ ):
743
+ continue
744
+ matches.append((max(0.0, gap), target_lane))
745
+ if len(matches) == 1:
746
+ gap, target_lane = matches[0]
747
+ moves.append((candidate_bbox[1] + gap, source_lane, target_lane, candidate))
748
+ if not moves:
749
+ return
750
+ _score, source_lane, target_lane, candidate = min(
751
+ moves,
752
+ key=lambda item: item[0],
753
+ )
754
+ if candidate not in source_lane.lines:
755
+ continue
756
+ source_lane.lines.remove(candidate)
757
+ target_lane.lines.append(candidate)
758
+ target_lane.lines.sort(
759
+ key=lambda item: (item[1][1], item[1][0], item[0].source_index),
760
+ )
761
+
762
+
763
+ def _reattach_repeated_indented_span_tails(
764
+ span_lane: _TextLane,
765
+ regular_lanes: list[_TextLane],
766
+ median_height: float,
767
+ ) -> None:
768
+ """识别重复的跨栏首行与缩进短尾,并把短尾统一迁回跨栏栏带。"""
769
+
770
+ span_rows = sorted(
771
+ span_lane.lines,
772
+ key=lambda item: (item[1][1], item[1][0], item[0].source_index),
773
+ )
774
+ matches: list[tuple[_TextLane, tuple[_LineItem, BBox], tuple[_LineItem, BBox], float]] = []
775
+ for span_index, span_row in enumerate(span_rows):
776
+ span_line, span_bbox = span_row
777
+ next_span_top = span_rows[span_index + 1][1][1] if span_index + 1 < len(span_rows) else float("inf")
778
+ for regular_lane in regular_lanes:
779
+ for candidate in regular_lane.lines:
780
+ candidate_line, candidate_bbox = candidate
781
+ if candidate_line.semantic_type != span_line.semantic_type:
782
+ continue
783
+ gap = _effective_text_row_gap(span_row, candidate)
784
+ indent = candidate_bbox[0] - span_bbox[0]
785
+ span_height = _line_effective_height(*span_row)
786
+ candidate_height = _line_effective_height(*candidate)
787
+ if (
788
+ candidate_bbox[1] <= span_bbox[1]
789
+ or candidate_bbox[1] >= next_span_top
790
+ or not -0.25 * median_height <= gap <= 0.75 * median_height
791
+ or not 0.75 * median_height <= indent <= 6.0 * median_height
792
+ or max(span_height, candidate_height) / min(span_height, candidate_height) > 1.35
793
+ or not _title_fonts_compatible(span_line, candidate_line)
794
+ ):
795
+ continue
796
+ has_parallel_peer = any(
797
+ other_line is not candidate_line and _bbox_axis_overlap_ratio(candidate_bbox, other_bbox, axis="y") >= 0.5
798
+ for lane in regular_lanes
799
+ for other_line, other_bbox in lane.lines
800
+ )
801
+ if not has_parallel_peer:
802
+ matches.append((regular_lane, candidate, span_row, indent))
803
+
804
+ if len(matches) < 2:
805
+ return
806
+ median_indent = statistics.median(match[3] for match in matches)
807
+ supported = [match for match in matches if abs(match[3] - median_indent) <= max(0.75 * median_height, 0.25 * median_indent)]
808
+ if len(supported) < 2:
809
+ return
810
+ for regular_lane, candidate, _span_row, _indent in supported:
811
+ if candidate not in regular_lane.lines:
812
+ continue
813
+ regular_lane.lines.remove(candidate)
814
+ span_lane.lines.append(candidate)
815
+ span_lane.left = min(span_lane.left, candidate[1][0])
816
+ span_lane.right = max(span_lane.right, candidate[1][2])
817
+ span_lane.lines.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
818
+
819
+
820
+ def _estimate_lane_gap(lane: _TextLane) -> tuple[float, float]:
821
+ """从栏带内兼容相邻行的较小间隙簇估计常规净空和 MAD。"""
822
+
823
+ lane.lines.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
824
+ heights = [_line_effective_height(line, bbox) for line, bbox in lane.lines]
825
+ median_height = statistics.median(heights) if heights else 1.0
826
+ gaps: list[float] = []
827
+ for previous, current in zip(lane.lines, lane.lines[1:]):
828
+ previous_line, previous_bbox = previous
829
+ current_line, current_bbox = current
830
+ if previous_line.visual_row_id == current_line.visual_row_id and (
831
+ previous_line.split_from_row or current_line.split_from_row
832
+ ):
833
+ continue
834
+ previous_height = _line_effective_height(previous_line, previous_bbox)
835
+ current_height = _line_effective_height(current_line, current_bbox)
836
+ if max(previous_height, current_height) / min(previous_height, current_height) > 1.35:
837
+ continue
838
+ pair_height = max(previous_height, current_height)
839
+ gap = _effective_text_row_gap(previous, current)
840
+ if gap < -0.25 * pair_height or gap > 2.0 * pair_height:
841
+ continue
842
+ if (
843
+ _bbox_axis_overlap_ratio(previous_bbox, current_bbox, axis="x") < 0.5
844
+ and abs(previous_bbox[0] - current_bbox[0]) > 1.5 * median_height
845
+ ):
846
+ continue
847
+ # PDF 字符框常在相邻基线间产生极小重叠;按零净空计入常规行距统计。
848
+ gaps.append(max(0.0, gap))
849
+
850
+ if not gaps:
851
+ return 0.35 * median_height, 0.0
852
+ sorted_gaps = sorted(gaps)
853
+ lower_count = max(1, math.ceil(len(sorted_gaps) * 0.6))
854
+ lower_gaps = sorted_gaps[:lower_count]
855
+ regular_gap = statistics.median(lower_gaps)
856
+ gap_mad = statistics.median(abs(gap - regular_gap) for gap in lower_gaps)
857
+ return regular_gap, gap_mad
858
+
859
+
860
+ def _is_structural_typography_gap(
861
+ previous_height: float,
862
+ current_height: float,
863
+ vertical_gap: float,
864
+ regular_gap: float,
865
+ gap_mad: float,
866
+ *,
867
+ reliable_style_change: bool = False,
868
+ ) -> bool:
869
+ """判断异常段间净空是否同时具有行高或可靠字体层级变化。"""
870
+
871
+ pair_height = max(previous_height, current_height)
872
+ minimum_height = max(0.1, min(previous_height, current_height))
873
+ prominent_gap = vertical_gap > regular_gap + max(
874
+ 0.75 * pair_height,
875
+ 3.0 * gap_mad,
876
+ )
877
+ return prominent_gap and (pair_height / minimum_height >= 1.12 or reliable_style_change)
878
+
879
+
880
+ def _should_connect_text_rows(
881
+ previous: tuple[_LineItem, BBox],
882
+ current: tuple[_LineItem, BBox],
883
+ lane: _TextLane,
884
+ regular_gap: float,
885
+ gap_mad: float,
886
+ table_bboxes: list[BBox],
887
+ axis_lines: list[_LocalAxisLine],
888
+ ) -> bool:
889
+ """综合局部间距、首行缩进、字体和障碍判断两个相邻视觉行是否同段。"""
890
+
891
+ previous_line, previous_bbox = previous
892
+ current_line, current_bbox = current
893
+ previous_height = _line_effective_height(previous_line, previous_bbox)
894
+ current_height = _line_effective_height(current_line, current_bbox)
895
+ pair_height = max(previous_height, current_height)
896
+ lane_width = max(0.1, lane.right - lane.left)
897
+ previous_width = previous_bbox[2] - previous_bbox[0]
898
+ current_width = current_bbox[2] - current_bbox[0]
899
+ vertical_gap = _effective_body_text_row_gap(previous, current)
900
+ if previous_line.visual_row_id == current_line.visual_row_id and (
901
+ previous_line.split_from_row or current_line.split_from_row
902
+ ):
903
+ return False
904
+ if current_height < 0.88 * previous_height and vertical_gap > regular_gap + max(0.25 * previous_height, 3.0 * gap_mad):
905
+ return False
906
+ both_fill_lane = previous_width >= 0.8 * lane_width and current_width >= 0.8 * lane_width
907
+ aligned_left_edges = abs(previous_bbox[0] - current_bbox[0]) <= 0.5 * pair_height
908
+ current_returns_to_lane_left = (
909
+ abs(current_bbox[0] - lane.left) <= 0.75 * pair_height
910
+ and -0.5 * pair_height <= previous_bbox[0] - lane.left <= 2.0 * pair_height
911
+ )
912
+ reliable_font_match = (
913
+ previous_line.font_signature is None
914
+ or current_line.font_signature is None
915
+ or previous_line.font_coverage < 0.75
916
+ or current_line.font_coverage < 0.75
917
+ or previous_line.font_signature == current_line.font_signature
918
+ or _font_signatures_share_family(
919
+ previous_line.font_signature,
920
+ current_line.font_signature,
921
+ )
922
+ or (current_width <= 0.5 * lane_width and previous_line.font_signature[1] == current_line.font_signature[1])
923
+ )
924
+ previous_indent = previous_bbox[0] - lane.left
925
+ repeated_indent_continuation = (
926
+ previous_indent >= max(5.0, 1.8 * pair_height)
927
+ and abs(current_bbox[0] - previous_bbox[0]) <= 0.5 * pair_height
928
+ and reliable_font_match
929
+ and -0.25 * pair_height <= vertical_gap <= regular_gap + max(0.75 * pair_height, 3.0 * gap_mad)
930
+ )
931
+ safe_short_tail = (
932
+ previous_width >= 0.75 * lane_width
933
+ and current_width <= 0.7 * lane_width
934
+ and (aligned_left_edges or current_returns_to_lane_left)
935
+ and reliable_font_match
936
+ and not _font_weights_conflict(previous_line, current_line)
937
+ and -0.25 * pair_height <= vertical_gap <= regular_gap + max(0.75 * pair_height, 3.0 * gap_mad)
938
+ )
939
+ height_ratio = max(previous_height, current_height) / min(previous_height, current_height)
940
+ font_style_changed = (
941
+ previous_line.font_signature is not None
942
+ and current_line.font_signature is not None
943
+ and previous_line.font_signature[1] != current_line.font_signature[1]
944
+ and not _font_signatures_share_family(
945
+ previous_line.font_signature,
946
+ current_line.font_signature,
947
+ )
948
+ )
949
+ reliable_style_conflict = (
950
+ previous_line.font_signature is not None
951
+ and current_line.font_signature is not None
952
+ and previous_line.font_coverage >= 0.75
953
+ and current_line.font_coverage >= 0.75
954
+ and (
955
+ (
956
+ previous_line.font_signature != current_line.font_signature
957
+ and not _font_signatures_share_family(
958
+ previous_line.font_signature,
959
+ current_line.font_signature,
960
+ )
961
+ )
962
+ or _font_weights_conflict(previous_line, current_line)
963
+ )
964
+ )
965
+ fallback_font_continuation = (
966
+ previous_line.font_signature is not None
967
+ and current_line.font_signature is not None
968
+ and previous_line.font_coverage >= 0.75
969
+ and current_line.font_coverage >= 0.75
970
+ and previous_line.font_signature[0] != current_line.font_signature[0]
971
+ and previous_line.font_signature[1] == current_line.font_signature[1]
972
+ and aligned_left_edges
973
+ and height_ratio <= 1.25
974
+ and not _font_weights_conflict(previous_line, current_line)
975
+ and -0.25 * pair_height <= vertical_gap <= regular_gap + max(0.35 * pair_height, 3.0 * gap_mad)
976
+ )
977
+ if font_style_changed and _font_weights_conflict(previous_line, current_line) and not fallback_font_continuation:
978
+ # 显式样式位与显著字重同时变化仍是硬边界,不能被满栏几何放宽。
979
+ return False
980
+ if (
981
+ not is_hyphen_at_line_end(previous_line.text)
982
+ and _is_structural_typography_gap(
983
+ previous_height,
984
+ current_height,
985
+ vertical_gap,
986
+ regular_gap,
987
+ gap_mad,
988
+ reliable_style_change=reliable_style_conflict,
989
+ )
990
+ and not fallback_font_continuation
991
+ ):
992
+ # 图注到正文等排版层级转换即使同栏满行,也不能被常规续行规则重新吸收。
993
+ return False
994
+ full_width_continuation = (
995
+ both_fill_lane
996
+ and aligned_left_edges
997
+ and not font_style_changed
998
+ and vertical_gap <= regular_gap + max(0.75 * min(previous_height, current_height), 3.0 * gap_mad)
999
+ )
1000
+ if height_ratio > 1.35 and not safe_short_tail and not full_width_continuation:
1001
+ # 满栏混合字体可跨字号续接,但显式正体/斜体等样式边界仍保持原分段语义。
1002
+ if not both_fill_lane or not aligned_left_edges or font_style_changed:
1003
+ return False
1004
+
1005
+ if vertical_gap < -0.25 * pair_height:
1006
+ return False
1007
+ if (
1008
+ _bbox_axis_overlap_ratio(previous_bbox, current_bbox, axis="x") < 0.5
1009
+ and abs(previous_bbox[0] - current_bbox[0]) > 1.5 * pair_height
1010
+ and not safe_short_tail
1011
+ ):
1012
+ return False
1013
+ if _connection_crosses_table(previous_line.bbox, current_line.bbox, table_bboxes):
1014
+ return False
1015
+ if _horizontal_rule_separates_rows(previous_bbox, current_bbox, lane, axis_lines):
1016
+ return False
1017
+
1018
+ gap_limit = max(
1019
+ regular_gap + max(0.5 * pair_height, 3.0 * gap_mad),
1020
+ 1.1 * pair_height,
1021
+ )
1022
+ # 排版断词可以跳过缩进、字体和短行规则,但仍须限制在邻近物理行内,
1023
+ # 避免页内远距离的 “cross-” 与后续标题被误拼为同一段。
1024
+ if is_hyphen_at_line_end(previous_line.text):
1025
+ return vertical_gap <= max(gap_limit, 1.8 * pair_height)
1026
+ if vertical_gap > gap_limit:
1027
+ return False
1028
+
1029
+ terminal_previous = bool(re.search(r"[.!?。!?::;;][\]\)})】》”’'\"]*$", previous_line.text.rstrip()))
1030
+ sparse_lane = sum(line.semantic_type is None for line, _bbox in lane.lines) <= 6
1031
+ if (
1032
+ terminal_previous
1033
+ and not repeated_indent_continuation
1034
+ and ((sparse_lane and vertical_gap > 0.65 * pair_height) or vertical_gap > regular_gap + 0.5 * pair_height)
1035
+ ):
1036
+ return False
1037
+
1038
+ # 局部版心可能比整栏推断边界更靠左,缩进需同时参考上一物理行。
1039
+ local_lane_left = min(lane.left, previous_bbox[0])
1040
+ local_lane_width = max(0.1, lane.right - local_lane_left)
1041
+ next_indent = current_bbox[0] - local_lane_left
1042
+ previous_fill = max(0.0, previous_bbox[2] - local_lane_left) / local_lane_width
1043
+ if (
1044
+ next_indent >= max(5.0, 0.65 * pair_height)
1045
+ and (previous_fill <= 0.8 or terminal_previous)
1046
+ and not safe_short_tail
1047
+ and not repeated_indent_continuation
1048
+ ):
1049
+ # 已确认的同左缘短尾优先于栏左缘缩进,避免参考文献冒号后的末行被切断。
1050
+ return False
1051
+
1052
+ abnormal_gap = vertical_gap > regular_gap + max(0.25 * pair_height, 3.0 * gap_mad)
1053
+ if (
1054
+ reliable_style_conflict
1055
+ and (abnormal_gap or min(previous_width, current_width) <= 0.7 * lane_width)
1056
+ and not both_fill_lane
1057
+ and not safe_short_tail
1058
+ and not fallback_font_continuation
1059
+ ):
1060
+ return False
1061
+ if abnormal_gap and min(previous_width, current_width) <= 0.65 * lane_width and not safe_short_tail:
1062
+ return False
1063
+ return True
1064
+
1065
+
1066
+ def _horizontal_rule_separates_rows(
1067
+ previous_bbox: BBox,
1068
+ current_bbox: BBox,
1069
+ lane: _TextLane,
1070
+ axis_lines: list[_LocalAxisLine],
1071
+ ) -> bool:
1072
+ """检查两个相邻文本行之间是否存在覆盖当前栏带的长水平规则线。"""
1073
+
1074
+ if current_bbox[1] <= previous_bbox[3]:
1075
+ return False
1076
+ lane_width = max(0.1, lane.right - lane.left)
1077
+ for axis_line in axis_lines:
1078
+ if axis_line.orientation != "horizontal":
1079
+ continue
1080
+ line_y = _bbox_center_y(axis_line.bbox)
1081
+ if not previous_bbox[3] <= line_y <= current_bbox[1]:
1082
+ continue
1083
+ overlap = max(0.0, min(axis_line.bbox[2], lane.right) - max(axis_line.bbox[0], lane.left))
1084
+ if overlap / lane_width >= 0.6:
1085
+ return True
1086
+ return False
1087
+
1088
+
1089
+ def _connection_crosses_table(
1090
+ first_bbox: BBox,
1091
+ second_bbox: BBox,
1092
+ table_bboxes: list[BBox],
1093
+ ) -> bool:
1094
+ """检查两行中心连接区域是否穿过已确认表格。"""
1095
+
1096
+ first_center = (_bbox_center_x(first_bbox), _bbox_center_y(first_bbox))
1097
+ second_center = (_bbox_center_x(second_bbox), _bbox_center_y(second_bbox))
1098
+ connector = _coerce_bbox(
1099
+ (
1100
+ min(first_center[0], second_center[0]) - 0.1,
1101
+ min(first_center[1], second_center[1]) - 0.1,
1102
+ max(first_center[0], second_center[0]) + 0.1,
1103
+ max(first_center[1], second_center[1]) + 0.1,
1104
+ )
1105
+ )
1106
+ return connector is not None and any(_bbox_intersects(connector, table_bbox) for table_bbox in table_bboxes)